diff --git a/README.md b/README.md index 150c2729..1c0f43fe 100644 --- a/README.md +++ b/README.md @@ -515,8 +515,9 @@ Because SkillHub speaks the same `SKILL.md` format, skills from `anthropics/skil git clone https://github.com/anthropics/skills # ...and publish it into your private SkillHub registry -export CLAWHUB_REGISTRY=https://skillhub.your-company.com -npx clawhub publish ./skills// +export SKILLHUB_REGISTRY=https://skillhub.your-company.com +export SKILLHUB_TOKEN=YOUR_API_TOKEN +npx @astron-team/skillhub@latest publish ./skills// ``` > ⚖️ **Licensing**: honor each skill's own license when republishing. Most skills in @@ -546,16 +547,18 @@ npx clawhub search email npx clawhub install my-skill npx clawhub install my-namespace--my-skill -# Publish to global namespace -npx clawhub publish ./my-skill --slug my-skill --version 1.0.0 - -# Publish to a team namespace such as my-space -npx clawhub publish ./my-skill --slug my-space--my-skill --version 1.0.0 +# Publishing uses the first-party SkillHub CLI +export SKILLHUB_REGISTRY=https://skillhub.your-company.com +export SKILLHUB_TOKEN=YOUR_API_TOKEN +npx @astron-team/skillhub@latest publish ./my-skill --namespace my-space ``` `my-space--my-skill` is the canonical compat slug. SkillHub parses it as namespace `my-space` plus skill slug `my-skill`. +ClawHub compatibility covers search, inspection, and installation. Its publish +protocol is not compatible with SkillHub; use the first-party CLI shown above. + > 💡 **Tip**: The above commands are not only applicable to OpenClaw, but also to other CLI Coding Agents or Agent assistants by specifying the installation directory (`--dir`). For example: `npx clawhub --dir ~/.claude/skills install my-skill` 📖 **[Complete OpenClaw Integration Guide →](./docs/openclaw-integration.md)** diff --git a/README_zh.md b/README_zh.md index 90ee1ae9..4ea2d82b 100644 --- a/README_zh.md +++ b/README_zh.md @@ -401,8 +401,9 @@ Agent Skill 目录——都可以直接发布到你的注册中心: git clone https://github.com/anthropics/skills # ……并将其发布到你的私有 SkillHub 注册中心 -export CLAWHUB_REGISTRY=https://skillhub.your-company.com -npx clawhub publish ./skills/<分类>/<技能名> +export SKILLHUB_REGISTRY=https://skillhub.your-company.com +export SKILLHUB_TOKEN=YOUR_API_TOKEN +npx @astron-team/skillhub@latest publish ./skills/<分类>/<技能名> ``` > ⚖️ **许可提示**:转发布时请遵守每个技能各自的许可证。`anthropics/skills` 中大多数技能 @@ -431,16 +432,18 @@ npx clawhub search email npx clawhub install my-skill npx clawhub install my-namespace--my-skill -# 发布到 global 空间 -npx clawhub publish ./my-skill --slug my-skill --version 1.0.0 - -# 发布到如 my-space 这样的团队空间 -npx clawhub publish ./my-skill --slug my-space--my-skill --version 1.0.0 +# 发布请使用第一方 SkillHub CLI +export SKILLHUB_REGISTRY=https://skillhub.your-company.com +export SKILLHUB_TOKEN=YOUR_API_TOKEN +npx @astron-team/skillhub@latest publish ./my-skill --namespace my-space ``` 其中 `my-space--my-skill` 是兼容层使用的 canonical slug,SkillHub 会将其解析为 namespace `my-space` 和 skill slug `my-skill`。 +ClawHub 兼容范围包含搜索、查看和安装;其发布协议与 SkillHub 不兼容。 +发布请使用上面的第一方 CLI。 + > 💡 **提示**:上述命令不仅适用于 OpenClaw,通过指定安装目录(`--dir`),也可适用于其他的 CLI Coding Agent 或 Agent 助手。例如:`npx clawhub --dir ~/.claude/skills install my-skill` 📖 **[完整 OpenClaw 集成指南 →](./docs/openclaw-integration.md)** diff --git a/cli/CHANGELOG.md b/cli/CHANGELOG.md index 8a378be0..3a2b8cb9 100644 --- a/cli/CHANGELOG.md +++ b/cli/CHANGELOG.md @@ -4,8 +4,20 @@ All notable CLI behavior changes are documented in this file. ## Unreleased +### Added + +- Add `skillhub upgrade ` for bounded, explicit upgrades of already-installed Skills, + including side-effect-free `--check`, structured `--json`, local-change protection, and target + filters. + ### Fixed +- Prevent `--force` from overwriting an unmanaged or different-source Skill at the same target path. + Source ownership is the full `registry + namespace + slug` identity. +- Reject registry downgrades and partial-target updates that the shared inventory version cannot + represent safely. +- Exclude installer-owned `.skillhub/` state when publishing a local Skill directory. + - Resolve `namespace/slug`, `@namespace/slug`, and `namespace--slug` coordinates against their declared namespace instead of silently falling back to `global`. diff --git a/cli/README.md b/cli/README.md index 11014547..d54f6045 100644 --- a/cli/README.md +++ b/cli/README.md @@ -171,7 +171,7 @@ skillhub install pdf-parser --agent codex --agent claude-code # Install to custom directory skillhub install pdf-parser --dir ~/.claude/skills -# Force overwrite existing installation +# Reinstall a SkillHub-managed installation from the same source skillhub install pdf-parser --force ``` @@ -228,17 +228,52 @@ For a custom path or an unsupported Agent directory, use `--dir` to specify the ```json { + "schemaVersion": 1, "registry": "https://skill.xfyun.cn", "namespace": "global", "slug": "pdf-parser", "version": "1.0.0", + "versionId": 123, "fingerprint": "sha256:...", + "files": { "SKILL.md": "sha256..." }, "source": "skillhub", "agent": "codex", "installedAt": "2026-04-28T06:00:00.000Z" } ``` +The CLI creates `.skillhub/metadata.json` after extracting a downloaded package. It is not part of +the published ZIP and is excluded when a managed directory is published again. + +## ⬆️ Upgrade Installed Skills + +`upgrade` only operates on explicitly selected, SkillHub-managed local installations. It never +installs a missing Skill and has no implicit upgrade-all mode. + +```bash +# Preview without changing files +skillhub upgrade @global/skillhub-registry --check + +# Upgrade one or a bounded list of installed Skills +skillhub upgrade @global/skillhub-registry +skillhub upgrade @team/code-review @team/java-guide + +# Machine-readable plan +skillhub upgrade @team/code-review --check --json +``` + +The source identity is `registry + namespace + slug`. `--force` may replace local changes only when +that full identity matches the installation metadata; it never overwrites an unmanaged directory or +a Skill installed from another source. + +All targets in one inventory entry are upgraded together. A filter that selects only part of that +entry is rejected because the current inventory format stores one shared version for all targets. +The command also keeps the local files when the registry resolves to an older version. +If a multi-Skill run fails after an earlier upgrade commits, execution stops and reports each item +as `upgraded`, `failed`, or `not-attempted`; a committed upgrade is never rolled back implicitly. +New installations store absolute target paths. An older inventory entry with relative target paths +must be reinstalled before upgrade because its original working directory cannot be recovered safely. + ## 🔄 Namespace Workspaces Use namespace synchronization when an Agent workspace should maintain all installable skills from one team space. @@ -267,6 +302,12 @@ skillhub sync push --all --namespace team-a --submit-review The default workspace is `/.agents/skills`. Pull never overwrites local changes unless `--force` is supplied. Remote removals are reported as `orphaned` and are retained unless `--prune` is supplied. Both destructive cases still require explicit flags. +Sync compares both the published version and package fingerprint. An exact match is `up-to-date`, +while a newer version is `update-available` even when its content is unchanged. An older remote +version, an unorderable version pair, or changed remote content without a version bump is `blocked`. +`--force` cannot bypass these release-safety checks; verify the release and use an explicit +`skillhub install` when replacement is intentional. + Workspace push is non-overwriting: an existing namespace/slug/version is reported as a conflict, including versions that are still uploaded or pending review. Other skills in the same `--all` run continue processing. Namespace sync writes `.skillhub/namespace-sync.json` in the workspace and per-skill `.skillhub/metadata.json` files. These files contain the registry coordinate, published version, aggregate fingerprint, and file hashes used by `status` and `diff`. @@ -402,6 +443,7 @@ Update mechanism: | `skillhub whoami [--registry ] [--token ] [--json]` | Validate current token and display user information | | `skillhub search [--registry ] [--token ] [--limit ] [--json]` | Search published skills | | `skillhub install [--scope ] [--namespace ] [--version ] [--agent ] [--dir ] [--force] [--registry ] [--token ] [--json]` | Install a skill | +| `skillhub upgrade [--namespace ] [--agent ] [--dir ] [--registry ] [--check] [--force] [--json]` | Upgrade explicitly selected installed skills | | `skillhub list [--agent ] [--dir ] [--registry ] [--json]` | List installed skills | | `skillhub remove [--agent ] [--all] [--remote] [--hard] [--namespace ] [--registry ] [--token ] [--json]` | Remove a skill | | `skillhub doctor [--json]` | Scan project directory and rebuild local inventory | @@ -447,13 +489,16 @@ skillhub search test --registry https://skillhub.example.com ```bash # Use --force to overwrite -skillhub install pdf-parser --force +skillhub install pdf-parser --force # same SkillHub source only # Or remove first then install skillhub remove pdf-parser skillhub install pdf-parser ``` +`--force` does not bypass source ownership. Move or explicitly remove an unmanaged or different-source +directory before installing another Skill with the same visible slug. + ### Corrupted Inventory ```bash diff --git a/cli/bun.lock b/cli/bun.lock index 39760c2d..107bcc74 100644 --- a/cli/bun.lock +++ b/cli/bun.lock @@ -8,12 +8,14 @@ "cac": "^6.7.14", "fflate": "^0.8.2", "prompts": "^2.4.2", + "proper-lockfile": "4.1.2", "semver": "^7.6.3", "zod": "^3.24.1", }, "devDependencies": { "@types/bun": "^1.3.13", "@types/prompts": "^2.4.9", + "@types/proper-lockfile": "4.1.4", "@types/semver": "^7.5.8", "@typescript-eslint/eslint-plugin": "^7.18.0", "@typescript-eslint/parser": "^7.18.0", @@ -49,6 +51,10 @@ "@types/prompts": ["@types/prompts@2.4.9", "https://registry.npmmirror.com/@types/prompts/-/prompts-2.4.9.tgz", { "dependencies": { "@types/node": "*", "kleur": "^3.0.3" } }, "sha512-qTxFi6Buiu8+50/+3DGIWLHM6QuWsEKugJnnP6iv2Mc4ncxE4A/OJkjuVOA+5X0X1S/nq5VJRa8Lu+nwcvbrKA=="], + "@types/proper-lockfile": ["@types/proper-lockfile@4.1.4", "https://registry.npmmirror.com/@types/proper-lockfile/-/proper-lockfile-4.1.4.tgz", { "dependencies": { "@types/retry": "*" } }, "sha512-uo2ABllncSqg9F1D4nugVl9v93RmjxF6LJzQLMLDdPaXCUIDPeOJ21Gbqi43xNKzBi/WQ0Q0dICqufzQbMjipQ=="], + + "@types/retry": ["@types/retry@0.12.5", "https://registry.npmmirror.com/@types/retry/-/retry-0.12.5.tgz", {}, "sha512-3xSjTp3v03X/lSQLkczaN9UIEwJMoMCA1+Nb5HfbJEQWogdeQIyVtTvxPXDQjZ5zws8rFQfVfRdz03ARihPJgw=="], + "@types/semver": ["@types/semver@7.7.1", "https://registry.npmmirror.com/@types/semver/-/semver-7.7.1.tgz", {}, "sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA=="], "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@7.18.0", "https://registry.npmmirror.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.18.0.tgz", { "dependencies": { "@eslint-community/regexpp": "^4.10.0", "@typescript-eslint/scope-manager": "7.18.0", "@typescript-eslint/type-utils": "7.18.0", "@typescript-eslint/utils": "7.18.0", "@typescript-eslint/visitor-keys": "7.18.0", "graphemer": "^1.4.0", "ignore": "^5.3.1", "natural-compare": "^1.4.0", "ts-api-utils": "^1.3.0" }, "peerDependencies": { "@typescript-eslint/parser": "^7.0.0", "eslint": "^8.56.0" } }, "sha512-94EQTWZ40mzBc42ATNIBimBEDltSJ9RQHCC8vc/PDbxi4k8dVwUAv4o98dk50M1zB+JGFxp43FP7f8+FP8R6Sw=="], @@ -163,6 +169,8 @@ "globby": ["globby@11.1.0", "https://registry.npmmirror.com/globby/-/globby-11.1.0.tgz", { "dependencies": { "array-union": "^2.1.0", "dir-glob": "^3.0.1", "fast-glob": "^3.2.9", "ignore": "^5.2.0", "merge2": "^1.4.1", "slash": "^3.0.0" } }, "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g=="], + "graceful-fs": ["graceful-fs@4.2.11", "https://registry.npmmirror.com/graceful-fs/-/graceful-fs-4.2.11.tgz", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], + "graphemer": ["graphemer@1.4.0", "https://registry.npmmirror.com/graphemer/-/graphemer-1.4.0.tgz", {}, "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag=="], "has-flag": ["has-flag@4.0.0", "https://registry.npmmirror.com/has-flag/-/has-flag-4.0.0.tgz", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], @@ -239,12 +247,16 @@ "prompts": ["prompts@2.4.2", "https://registry.npmmirror.com/prompts/-/prompts-2.4.2.tgz", { "dependencies": { "kleur": "^3.0.3", "sisteransi": "^1.0.5" } }, "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q=="], + "proper-lockfile": ["proper-lockfile@4.1.2", "https://registry.npmmirror.com/proper-lockfile/-/proper-lockfile-4.1.2.tgz", { "dependencies": { "graceful-fs": "^4.2.4", "retry": "^0.12.0", "signal-exit": "^3.0.2" } }, "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA=="], + "punycode": ["punycode@2.3.1", "https://registry.npmmirror.com/punycode/-/punycode-2.3.1.tgz", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], "queue-microtask": ["queue-microtask@1.2.3", "https://registry.npmmirror.com/queue-microtask/-/queue-microtask-1.2.3.tgz", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="], "resolve-from": ["resolve-from@4.0.0", "https://registry.npmmirror.com/resolve-from/-/resolve-from-4.0.0.tgz", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="], + "retry": ["retry@0.12.0", "https://registry.npmmirror.com/retry/-/retry-0.12.0.tgz", {}, "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow=="], + "reusify": ["reusify@1.1.0", "https://registry.npmmirror.com/reusify/-/reusify-1.1.0.tgz", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="], "rimraf": ["rimraf@3.0.2", "https://registry.npmmirror.com/rimraf/-/rimraf-3.0.2.tgz", { "dependencies": { "glob": "^7.1.3" }, "bin": { "rimraf": "bin.js" } }, "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA=="], @@ -257,6 +269,8 @@ "shebang-regex": ["shebang-regex@3.0.0", "https://registry.npmmirror.com/shebang-regex/-/shebang-regex-3.0.0.tgz", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], + "signal-exit": ["signal-exit@3.0.7", "https://registry.npmmirror.com/signal-exit/-/signal-exit-3.0.7.tgz", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], + "sisteransi": ["sisteransi@1.0.5", "https://registry.npmmirror.com/sisteransi/-/sisteransi-1.0.5.tgz", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="], "slash": ["slash@3.0.0", "https://registry.npmmirror.com/slash/-/slash-3.0.0.tgz", {}, "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q=="], diff --git a/cli/package.json b/cli/package.json index 8e4105a3..d9f8b650 100644 --- a/cli/package.json +++ b/cli/package.json @@ -48,12 +48,14 @@ "cac": "^6.7.14", "fflate": "^0.8.2", "prompts": "^2.4.2", + "proper-lockfile": "4.1.2", "semver": "^7.6.3", "zod": "^3.24.1" }, "devDependencies": { "@types/bun": "^1.3.13", "@types/prompts": "^2.4.9", + "@types/proper-lockfile": "4.1.4", "@types/semver": "^7.5.8", "@typescript-eslint/eslint-plugin": "^7.18.0", "@typescript-eslint/parser": "^7.18.0", diff --git a/cli/src/commands/help.ts b/cli/src/commands/help.ts index 37857834..7b225964 100644 --- a/cli/src/commands/help.ts +++ b/cli/src/commands/help.ts @@ -43,6 +43,15 @@ export const commands = { 'skillhub install pdf-parser --scope project --agent codex' ] }, + upgrade: { + summary: 'Upgrade explicitly selected installed skills', + usage: 'skillhub upgrade [--namespace ] [--agent ] [--dir ] [--registry ] [--check] [--force] [--json]', + examples: [ + 'skillhub upgrade @global/skillhub-registry', + 'skillhub upgrade @team/code-review @team/java-guide --check --json', + 'skillhub upgrade code-review --namespace team --agent codex' + ] + }, sync: { summary: 'Synchronize and maintain namespace workspaces', usage: 'skillhub sync [options]', diff --git a/cli/src/commands/install.ts b/cli/src/commands/install.ts index 009b9acc..3ab3d678 100644 --- a/cli/src/commands/install.ts +++ b/cli/src/commands/install.ts @@ -115,7 +115,16 @@ export async function installCommand( }) if (options.json) { - return JSON.stringify({ ok: true, namespace, slug, installed: result.installed }) + return JSON.stringify({ + ok: true, + namespace, + slug, + installed: result.installed, + ...(result.warnings?.length ? { warnings: result.warnings } : {}) + }) } - return result.installed.map(i => `Installed ${namespace}/${slug} -> ${i.dir} (${i.agent})`).join('\n') + return [ + ...result.installed.map(i => `Installed ${namespace}/${slug} -> ${i.dir} (${i.agent})`), + ...(result.warnings ?? []).map(warning => `Warning: ${warning}`) + ].join('\n') } diff --git a/cli/src/commands/publish.ts b/cli/src/commands/publish.ts index 1c73202f..6f9c5dbf 100644 --- a/cli/src/commands/publish.ts +++ b/cli/src/commands/publish.ts @@ -49,7 +49,9 @@ export async function publishCommand(path: string, options: PublishCommandOption throw new CliError(`file must be a zip archive: ${path}`, EXIT.filesystem, { path }) } } else if (pathStat.isDirectory()) { - archiveBlob = await createZip(path) + archiveBlob = await createZip(path, { + exclude: relativePath => relativePath === '.skillhub' || relativePath.startsWith('.skillhub/') + }) archiveName = `${basename(path)}.zip` } else { throw new CliError(`path must be a file or directory: ${path}`, EXIT.filesystem, { path }) diff --git a/cli/src/commands/sync.ts b/cli/src/commands/sync.ts index 3c998ea5..183c4a3e 100644 --- a/cli/src/commands/sync.ts +++ b/cli/src/commands/sync.ts @@ -47,10 +47,15 @@ export async function syncPullCommand(options: SyncPullOptions): Promise const output = renderPullResult(result, Boolean(options.json), Boolean(options.check)) if (result.failures.length > 0) { process.stdout.write(`${output}\n`) - throw new CliError('namespace sync completed with failures', EXIT.generic, { - namespace: context.namespace, - failures: result.failures - }) + const blocked = result.entries.filter(entry => entry.status === 'blocked') + throw new CliError( + blocked.length > 0 ? 'namespace sync blocked by remote version safety checks' : 'namespace sync completed with failures', + blocked.length > 0 ? EXIT.validation : EXIT.generic, + { + namespace: context.namespace, + failures: result.failures + } + ) } return output } @@ -135,7 +140,7 @@ async function resolveSyncContext(options: SyncCommonOptions): Promise<{ return { client: new SkillHubClient(registry, token), registry, token, namespace, rootDir } } -function renderPullResult(result: PullResult, json: boolean, check: boolean): string { +export function renderPullResult(result: PullResult, json: boolean, check: boolean): string { if (json) { return JSON.stringify({ ok: result.failures.length === 0, check, ...result }) } @@ -145,6 +150,7 @@ function renderPullResult(result: PullResult, json: boolean, check: boolean): st ...result.entries .filter(entry => !result.actions.some(action => action.slug === entry.slug)) .map(entry => `${entry.status.padEnd(16)} ${entry.slug}`), + ...result.warnings.map(item => `warning ${item.slug}: ${item.message}`), ...result.failures.map(item => `failed ${item.slug}: ${item.message}`) ] return lines.join('\n') diff --git a/cli/src/commands/upgrade.ts b/cli/src/commands/upgrade.ts new file mode 100644 index 00000000..ca0000fc --- /dev/null +++ b/cli/src/commands/upgrade.ts @@ -0,0 +1,136 @@ +import { CredentialsStore } from '../stores/credentials-store' +import { resolveToken } from '../services/registry-service' +import { CliError } from '../shared/errors' +import { EXIT } from '../shared/constants' +import { + executeSkillUpgradePlan, + planSkillUpgrades, + type UpgradeExecutionResult, + type UpgradePlan +} from '../services/upgrade-service' + +export interface UpgradeCommandOptions { + namespace?: string | undefined + agent?: string[] | undefined + dir?: string | undefined + registry?: string | undefined + token?: string | undefined + check?: boolean | undefined + force?: boolean | undefined + json?: boolean | undefined +} + +export async function upgradeCommand(coordinates: string[], options: UpgradeCommandOptions): Promise { + const credentials = new CredentialsStore() + const tokenForRegistry = async (registry: string): Promise => + resolveToken(options, process.env, await credentials.getToken(registry)) + + const plan = await planSkillUpgrades({ + coordinates, + namespace: options.namespace, + registry: options.registry, + agents: options.agent, + dir: options.dir, + force: Boolean(options.force), + tokenForRegistry + }) + if (plan.blocked > 0) { + const output = renderUpgradePlan(plan, { + check: Boolean(options.check), + executed: false + }, Boolean(options.json)) + process.stdout.write(`${output}\n`) + throw new CliError('upgrade plan contains blocked skills', EXIT.validation, { + blocked: plan.items.filter(item => item.action === 'blocked').map(item => ({ + coordinate: item.coordinate, + reason: item.reason + })) + }) + } + if (options.check) return renderUpgradePlan(plan, { check: true, executed: false }, Boolean(options.json)) + + const result = await executeSkillUpgradePlan(plan, { tokenForRegistry }) + const output = renderUpgradeResult(plan, result, Boolean(options.json)) + if (result.failed > 0) { + process.stdout.write(`${output}\n`) + const firstFailure = result.items.find(item => item.action === 'failed') + throw new CliError('one or more skills failed to upgrade', firstFailure?.exitCode ?? EXIT.generic, { + failed: result.items.filter(item => item.action === 'failed') + }) + } + return output +} + +function renderUpgradePlan( + plan: UpgradePlan, + state: { check: boolean; executed: boolean }, + json: boolean +): string { + if (json) { + return JSON.stringify({ + ok: plan.blocked === 0, + check: state.check, + summary: { upgrades: plan.upgrades, unchanged: plan.unchanged, blocked: plan.blocked }, + items: plan.items.map(item => ({ + coordinate: item.coordinate, + registry: item.registry, + currentVersion: item.currentVersion, + remoteVersion: item.remoteVersion, + action: item.action === 'upgrade' && state.executed ? 'upgraded' : item.action, + reason: item.reason, + changedFiles: item.changedFiles, + targets: item.targets + })) + }) + } + + const heading = state.executed ? 'Upgrade result' : 'Upgrade plan' + return [ + `${heading}: ${plan.upgrades} upgrade, ${plan.unchanged} unchanged, ${plan.blocked} blocked`, + ...plan.items.map(item => { + const action = item.action === 'upgrade' && state.executed ? 'upgraded' : item.action + const versions = item.remoteVersion ? ` ${item.currentVersion} -> ${item.remoteVersion}` : '' + const reason = item.reason ? ` (${item.reason})` : '' + return `${action.padEnd(10)} ${item.coordinate}${versions}${reason}` + }) + ].join('\n') +} + +export function renderUpgradeResult(plan: UpgradePlan, result: UpgradeExecutionResult, json: boolean): string { + const executionByCoordinate = new Map(result.items.map(item => [item.coordinate, item])) + const items = plan.items.map(item => ({ + coordinate: item.coordinate, + registry: item.registry, + currentVersion: item.currentVersion, + remoteVersion: item.remoteVersion, + action: executionByCoordinate.get(item.coordinate)?.action ?? item.action, + reason: executionByCoordinate.get(item.coordinate)?.reason ?? item.reason, + warnings: executionByCoordinate.get(item.coordinate)?.warnings, + changedFiles: item.changedFiles, + targets: item.targets + })) + + if (json) { + return JSON.stringify({ + ok: result.failed === 0, + check: false, + summary: { + upgraded: result.upgraded, + unchanged: result.unchanged, + failed: result.failed, + notAttempted: result.notAttempted + }, + items + }) + } + + return [ + `Upgrade result: ${result.upgraded} upgraded, ${result.unchanged} unchanged, ${result.failed} failed, ${result.notAttempted} not attempted`, + ...items.map(item => { + const versions = item.remoteVersion ? ` ${item.currentVersion} -> ${item.remoteVersion}` : '' + const reason = item.reason ? ` (${item.reason})` : '' + const warnings = item.warnings?.length ? ` [warning: ${item.warnings.join('; ')}]` : '' + return `${item.action.padEnd(13)} ${item.coordinate}${versions}${reason}${warnings}` + }) + ].join('\n') +} diff --git a/cli/src/index.ts b/cli/src/index.ts index d743d46a..4328bc5b 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -11,6 +11,7 @@ import { removeCommand, type RemoveCommandOptions } from './commands/remove' import { searchCommand } from './commands/search' import { syncDiffCommand, syncPullCommand, syncPushCommand, syncStatusCommand, type SyncCommonOptions, type SyncPullOptions, type SyncPushOptions } from './commands/sync' import { updateCommand } from './commands/update' +import { upgradeCommand, type UpgradeCommandOptions } from './commands/upgrade' import { versionCommand } from './commands/version' import { whoamiCommand } from './commands/whoami' import { EXIT } from './shared/constants' @@ -247,6 +248,23 @@ cli return runCommand(() => installCommand(slug, { ...options, agent: toArray(options.agent) }), Boolean(options.json)) }) +cli + .command('upgrade [...coordinates]', 'Upgrade explicitly selected installed skills') + .option('--namespace ', 'Filter a bare slug by namespace') + .option('--agent ', 'Filter installed targets by Agent (repeatable)') + .option('--dir ', 'Filter installed targets by directory') + .option('--registry ', 'Filter by installation source registry') + .option('--token ', 'API token override') + .option('--check', 'Show the exact plan without writing') + .option('--force', 'Replace local changes from the same source') + .option('--json', 'Output JSON') + .action((coordinates: string[], options: UpgradeCommandOptions & { agent?: string | string[] }) => { + return runCommand( + () => upgradeCommand(coordinates, { ...options, agent: toArray(options.agent) }), + Boolean(options.json) + ) + }) + cli .command('sync [path]', 'Synchronize and maintain a namespace workspace') .option('--namespace ', 'Namespace', { default: 'global' }) diff --git a/cli/src/services/install-service.ts b/cli/src/services/install-service.ts index 36ba1f4b..ba98ac5a 100644 --- a/cli/src/services/install-service.ts +++ b/cli/src/services/install-service.ts @@ -1,15 +1,22 @@ import { mkdir, mkdtemp, rename, rm, writeFile } from 'node:fs/promises' -import { join } from 'node:path' +import { join, relative, resolve } from 'node:path' import { SkillHubClient } from '../clients/skillhub-client' -import { InventoryStore } from '../stores/inventory-store' +import { InventoryStore, InventoryVersionConflictError } 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 { canonicalizeExistingPath, pathExists } from '../platform/paths' -import { snapshotSkillDirectory } from './skill-fingerprint' +import { diffSkillFiles, snapshotSkillDirectory } from './skill-fingerprint' +import { + readInstalledSkillMetadata, + sameInstalledSkillSource, + type InstalledSkillIdentity +} from './installed-skill-metadata' import type { AgentCandidate } from '../agents/types' import type { ResolveResponse } from '../clients/skillhub-client' +import type { Inventory } from '../stores/inventory-store' +import { acquireSkillTargetLock } from './skill-target-lock' export interface InstallOptions { registry: string @@ -21,19 +28,41 @@ export interface InstallOptions { force: boolean home?: string | undefined resolved?: ResolveResponse | undefined + expectedTargetFiles?: Record> | undefined + allowTargetDrift?: boolean | undefined + requireExistingTargets?: boolean | undefined + /** Internal test seam for lock lifecycle failures; production uses acquireSkillTargetLock. */ + acquireTargetLock?: typeof acquireSkillTargetLock +} + +export interface InstallResult { + installed: Array<{ agent: string; dir: string }> + warnings?: string[] +} + +interface StagedInstall { + target: AgentCandidate + skillDir: string + canonicalSkillDir: string + tempDir: string + installedAt: string + backupDir: string | null + movedIntoPlace: boolean } async function preflightInstallTargets( targets: AgentCandidate[], - slug: string, - force: boolean -): Promise> { + identity: InstalledSkillIdentity, + force: boolean, + inventory: Inventory +): Promise> { const seenSkillDirs = new Set() - const preparedTargets: Array<{ target: AgentCandidate; skillDir: string }> = [] + const preparedTargets: Array<{ target: AgentCandidate; skillDir: string; canonicalSkillDir: string }> = [] for (const target of targets) { - const canonicalRootDir = await canonicalizeExistingPath(target.rootDir) - const canonicalSkillDir = join(canonicalRootDir, slug) + const rootDir = resolve(target.rootDir) + const canonicalRootDir = await canonicalizeExistingPath(rootDir) + const canonicalSkillDir = join(canonicalRootDir, identity.slug) if (seenSkillDirs.has(canonicalSkillDir)) { throw new CliError(`multiple install targets resolve to ${canonicalSkillDir}`, EXIT.usage, { path: canonicalSkillDir, @@ -42,100 +71,311 @@ async function preflightInstallTargets( } seenSkillDirs.add(canonicalSkillDir) - const skillDir = join(target.rootDir, slug) - if (await pathExists(skillDir) && !force) { + // Use the canonical path only as an internal identity. Persist the resolved + // user path so macOS aliases and Windows short names remain stable in CLI output. + const resolvedTarget = { ...target, rootDir } + const skillDir = join(rootDir, identity.slug) + const exists = await pathExists(skillDir) + if (exists && !force) { throw new CliError(`skill already installed at ${skillDir}`, EXIT.filesystem, { path: skillDir, - next: 'pass --force to overwrite' + next: 'pass --force to replace a same-source installation' }) } - preparedTargets.push({ target, skillDir }) + if (exists) { + await assertReplaceableInstallation(skillDir, skillDir, canonicalSkillDir, identity, inventory) + } + preparedTargets.push({ target: resolvedTarget, skillDir, canonicalSkillDir }) } return preparedTargets } -export async function installSkill(options: InstallOptions): Promise<{ installed: Array<{ agent: string; dir: string }> }> { - const preparedTargets = await preflightInstallTargets(options.targets, options.slug, options.force) +export async function installSkill(options: InstallOptions): Promise { + const store = new InventoryStore(options.home) + const inventory = await store.read() + const preparedTargets = await preflightInstallTargets(options.targets, { + registry: options.registry, + namespace: options.namespace, + slug: options.slug + }, options.force, inventory) const client = new SkillHubClient(options.registry, options.token) const resolved = options.resolved ?? await client.resolve(options.namespace, options.slug, options.version) const response = await client.download(options.namespace, options.slug, resolved.version) const buffer = await readBoundedResponseBody(response) - const installed: Array<{ agent: string; dir: string }> = [] - const store = new InventoryStore(options.home) + const staged: StagedInstall[] = [] + try { + for (const { target, skillDir, canonicalSkillDir } of preparedTargets) { + await mkdir(target.rootDir, { recursive: true }) + const tempDir = await mkdtemp(join(target.rootDir, `.${options.slug}.install-`)) + try { + await extractZip(buffer, tempDir) - for (const { target, skillDir } of preparedTargets) { - await mkdir(target.rootDir, { recursive: true }) - const tempDir = await mkdtemp(join(target.rootDir, `.${options.slug}.install-`)) - let movedIntoPlace = false + const installedAt = new Date().toISOString() + const snapshot = await snapshotSkillDirectory(tempDir) + if (snapshot.fingerprint !== resolved.fingerprint) { + throw new CliError('downloaded skill fingerprint does not match the resolved release', EXIT.validation, { + coordinate: `@${options.namespace}/${options.slug}`, + expectedFingerprint: resolved.fingerprint, + actualFingerprint: snapshot.fingerprint, + next: 'retry the install after the registry release has been verified' + }) + } + const metaDir = join(tempDir, '.skillhub') + await mkdir(metaDir, { recursive: true }) + await writeFile(join(metaDir, 'metadata.json'), JSON.stringify({ + schemaVersion: 1, + registry: options.registry, + namespace: options.namespace, + slug: options.slug, + version: resolved.version, + versionId: resolved.versionId, + fingerprint: resolved.fingerprint, + files: snapshot.files, + source: 'skillhub', + agent: target.agent, + installedAt + }, null, 2)) + staged.push({ target, skillDir, canonicalSkillDir, tempDir, installedAt, backupDir: null, movedIntoPlace: false }) + } catch (error) { + await rm(tempDir, { recursive: true, force: true }).catch(() => {}) + throw error + } + } + const releases: Array<() => Promise> = [] + const warnings: string[] = [] + const acquireTargetLock = options.acquireTargetLock ?? acquireSkillTargetLock try { - await extractZip(buffer, tempDir) - - const installedAt = new Date().toISOString() - const snapshot = await snapshotSkillDirectory(tempDir) - 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, - fingerprint: resolved.fingerprint, - files: snapshot.files, - source: 'skillhub', - 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' - }) + for (const item of [...staged].sort((left, right) => left.skillDir.localeCompare(right.skillDir))) { + releases.push(await acquireTargetLock(item.target.rootDir, options.slug)) + } + + const lockedInventory = await store.read() + await assertNoPartialVersionChange(lockedInventory, staged, { + registry: options.registry, + namespace: options.namespace, + slug: options.slug + }, resolved) + + for (const item of staged) { + const targetExists = await pathExists(item.skillDir) + if (!targetExists && options.requireExistingTargets) { + throw new CliError(`installed target disappeared before upgrade commit: ${item.skillDir}`, EXIT.validation, { + path: item.skillDir, + next: 'reinstall the Skill explicitly before upgrading it' + }) + } + if (targetExists) { + if (!options.force) { + throw new CliError(`skill already installed at ${item.skillDir}`, EXIT.filesystem, { + path: item.skillDir, + next: 'pass --force to overwrite' + }) + } + const backupDir = `${item.skillDir}.skillhub-backup-${process.pid}-${Date.now()}` + await rename(item.skillDir, backupDir) + item.backupDir = backupDir + await assertReplaceableInstallation(item.backupDir, item.skillDir, item.canonicalSkillDir, { + registry: options.registry, + namespace: options.namespace, + slug: options.slug + }, lockedInventory) + const expectedFiles = options.expectedTargetFiles?.[item.skillDir] + if (expectedFiles && !options.allowTargetDrift) { + const currentSnapshot = await snapshotSkillDirectory(item.backupDir) + const changedFiles = diffSkillFiles(expectedFiles, currentSnapshot.files) + if (changedFiles.length > 0) { + throw new CliError(`local changes detected after upgrade planning at ${item.skillDir}`, EXIT.validation, { + path: item.skillDir, + changedFiles, + next: 'review the local changes and retry with --force only if replacement is intended' + }) + } + } + } + await rename(item.tempDir, item.skillDir) + item.movedIntoPlace = true } - const backupDir = `${skillDir}.skillhub-backup-${process.pid}-${Date.now()}` - let backupCreated = false try { - if (await pathExists(skillDir)) { - await rename(skillDir, backupDir) - backupCreated = true - } - await rename(tempDir, skillDir) - movedIntoPlace = true - - await store.replaceTargetAtInstallDir(options.registry, options.namespace, options.slug, resolved.version, { - agent: target.agent, - rootDir: target.rootDir, - installDir: skillDir, - installedAt - }, resolved.fingerprint) - - if (backupCreated) await rm(backupDir, { recursive: true, force: true }).catch(() => {}) + const replacedInstallDirs = await findEquivalentInventoryInstallDirs( + lockedInventory, + new Set(staged.map(item => item.canonicalSkillDir)) + ) + await store.replaceTargetsAtInstallDirs( + options.registry, + options.namespace, + options.slug, + resolved.version, + staged.map(item => ({ + agent: item.target.agent, + rootDir: item.target.rootDir, + installDir: item.skillDir, + installedAt: item.installedAt + })), + resolved.fingerprint, + replacedInstallDirs + ) } catch (error) { - if (movedIntoPlace) { - await rm(skillDir, { recursive: true, force: true }).catch(() => {}) - movedIntoPlace = false - } - if (backupCreated) await rename(backupDir, skillDir).catch(() => {}) - if (!options.force && await pathExists(skillDir)) { - throw new CliError(`skill already installed at ${skillDir}`, EXIT.filesystem, { - path: skillDir, - next: 'pass --force to overwrite' + if (error instanceof InventoryVersionConflictError) { + throw new CliError(error.message, EXIT.validation, { + coordinate: `@${options.namespace}/${options.slug}`, + retainedTargets: error.retainedTargets.map(target => ({ agent: target.agent, dir: target.installDir })), + next: 'select all installed targets for the upgrade' }) } throw error } + + for (const item of staged) { + if (item.backupDir) await rm(item.backupDir, { recursive: true, force: true }).catch(() => {}) + } + } catch (error) { + const rollbackFailures: Array<{ operation: string; path: string; error: string }> = [] + for (const item of [...staged].reverse()) { + if (item.movedIntoPlace) { + try { + await rm(item.skillDir, { recursive: true, force: true }) + item.movedIntoPlace = false + } catch (rollbackError) { + rollbackFailures.push({ + operation: 'remove replacement', + path: item.skillDir, + error: describeError(rollbackError) + }) + } + } + if (item.backupDir) { + const backupDir = item.backupDir + try { + await rename(backupDir, item.skillDir) + item.backupDir = null + } catch (rollbackError) { + rollbackFailures.push({ + operation: 'restore backup', + path: backupDir, + error: describeError(rollbackError) + }) + } + } + } + if (rollbackFailures.length > 0) { + throw new CliError('installation failed and rollback was incomplete', EXIT.filesystem, { + originalError: describeError(error), + rollbackFailures, + retainedBackups: staged.flatMap(item => item.backupDir ? [item.backupDir] : []), + next: 'restore the retained backup directories before retrying' + }) + } + throw error } finally { - if (!movedIntoPlace) { - await rm(tempDir, { recursive: true, force: true }).catch(() => {}) + for (const release of releases.reverse()) { + try { + await release() + } catch (error) { + warnings.push(`target lock cleanup failed: ${describeError(error)}`) + } } } - installed.push({ agent: target.agent, dir: skillDir }) + return { + installed: staged.map(item => ({ agent: item.target.agent, dir: item.skillDir })), + warnings + } + } finally { + for (const item of staged) { + if (!item.movedIntoPlace) await rm(item.tempDir, { recursive: true, force: true }).catch(() => {}) + } + } +} + +function describeError(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} + +async function assertNoPartialVersionChange( + inventory: Inventory, + staged: StagedInstall[], + identity: InstalledSkillIdentity, + resolved: ResolveResponse +): Promise { + const item = inventory.items.find(candidate => sameInstalledSkillSource(candidate, identity)) + if (!item) return + + const selectedInstallDirs = new Set(staged.map(candidate => candidate.canonicalSkillDir)) + const retainedTargets: typeof item.targets = [] + for (const target of item.targets) { + if (!selectedInstallDirs.has(await canonicalInventoryInstallDir(target))) retainedTargets.push(target) + } + if (retainedTargets.length === 0) return + if (item.version === resolved.version && item.fingerprint === resolved.fingerprint) return + + throw new CliError('partial-target install would create inconsistent versions', EXIT.validation, { + coordinate: `@${identity.namespace}/${identity.slug}`, + retainedTargets: retainedTargets.map(target => ({ agent: target.agent, dir: target.installDir })), + next: 'select all installed targets for the upgrade' + }) +} + +async function assertReplaceableInstallation( + metadataDir: string, + inventoryInstallDir: string, + canonicalInstallDir: string, + identity: InstalledSkillIdentity, + inventory: Inventory +): Promise { + const metadataResult = await readInstalledSkillMetadata(metadataDir) + if (metadataResult.status !== 'valid') { + throw new CliError(`cannot verify SkillHub ownership of ${inventoryInstallDir}`, EXIT.filesystem, { + path: inventoryInstallDir, + reason: metadataResult.status === 'missing' ? 'installation metadata is missing' : metadataResult.reason, + next: 'move or remove the existing directory before installing' + }) } - return { installed } + const inventoryOwners: Inventory['items'] = [] + for (const item of inventory.items) { + for (const target of item.targets) { + if (await canonicalInventoryInstallDir(target) === canonicalInstallDir) { + inventoryOwners.push(item) + break + } + } + } + if (!sameInstalledSkillSource(metadataResult.metadata, identity) || + inventoryOwners.some(owner => !sameInstalledSkillSource(owner, identity))) { + throw new CliError(`source conflict at ${inventoryInstallDir}`, EXIT.filesystem, { + path: inventoryInstallDir, + expected: identity, + actual: { + registry: metadataResult.metadata.registry, + namespace: metadataResult.metadata.namespace, + slug: metadataResult.metadata.slug + }, + next: 'choose another target directory or remove the conflicting skill explicitly' + }) + } +} + +async function findEquivalentInventoryInstallDirs( + inventory: Inventory, + canonicalInstallDirs: Set +): Promise { + const matches: string[] = [] + for (const item of inventory.items) { + for (const target of item.targets) { + if (canonicalInstallDirs.has(await canonicalInventoryInstallDir(target))) { + matches.push(target.installDir) + } + } + } + return matches +} + +async function canonicalInventoryInstallDir(target: { rootDir: string; installDir: string }): Promise { + const resolvedRoot = resolve(target.rootDir) + const canonicalRoot = await canonicalizeExistingPath(resolvedRoot) + return resolve(canonicalRoot, relative(resolvedRoot, resolve(target.installDir))) } diff --git a/cli/src/services/installed-skill-metadata.ts b/cli/src/services/installed-skill-metadata.ts new file mode 100644 index 00000000..36165739 --- /dev/null +++ b/cli/src/services/installed-skill-metadata.ts @@ -0,0 +1,82 @@ +import { readFile } from 'node:fs/promises' +import { join } from 'node:path' +import { pathExists } from '../platform/paths' + +export interface InstalledSkillIdentity { + registry: string + namespace: string + slug: string +} + +export interface InstalledSkillMetadata extends InstalledSkillIdentity { + schemaVersion?: number + version: string + versionId?: number + fingerprint?: string + files?: Record + source?: string + agent?: string + installedAt?: string +} + +export type InstalledMetadataReadResult = + | { status: 'missing' } + | { status: 'invalid'; reason: string } + | { status: 'valid'; metadata: InstalledSkillMetadata } + +export async function readInstalledSkillMetadata(skillDir: string): Promise { + const metadataPath = join(skillDir, '.skillhub', 'metadata.json') + if (!(await pathExists(metadataPath))) return { status: 'missing' } + + try { + const value = JSON.parse(await readFile(metadataPath, 'utf-8')) as unknown + if (!isRecord(value)) return { status: 'invalid', reason: 'metadata root must be an object' } + + for (const field of ['registry', 'namespace', 'slug', 'version'] as const) { + if (typeof value[field] !== 'string' || value[field].length === 0) { + return { status: 'invalid', reason: `metadata field "${field}" must be a non-empty string` } + } + } + if (value.source !== undefined && value.source !== 'skillhub') { + return { status: 'invalid', reason: 'metadata field "source" must be "skillhub"' } + } + if (value.schemaVersion !== undefined && value.schemaVersion !== 1) { + return { status: 'invalid', reason: 'metadata schema version is not supported' } + } + if (value.versionId !== undefined && + (!Number.isInteger(value.versionId) || (value.versionId as number) <= 0)) { + return { status: 'invalid', reason: 'metadata field "versionId" must be a positive integer' } + } + if (value.fingerprint !== undefined && typeof value.fingerprint !== 'string') { + return { status: 'invalid', reason: 'metadata field "fingerprint" must be a string' } + } + if (value.files !== undefined && !isStringRecord(value.files)) { + return { status: 'invalid', reason: 'metadata field "files" must map paths to hashes' } + } + + return { status: 'valid', metadata: value as unknown as InstalledSkillMetadata } + } catch { + return { status: 'invalid', reason: 'metadata is not valid JSON' } + } +} + +export function sameInstalledSkillSource( + left: InstalledSkillIdentity, + right: InstalledSkillIdentity +): boolean { + return normalizeRegistry(left.registry) === normalizeRegistry(right.registry) && + left.namespace === right.namespace && + left.slug === right.slug +} + +function normalizeRegistry(registry: string): string { + return registry.replace(/\/+$/, '') +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function isStringRecord(value: unknown): value is Record { + return isRecord(value) && Object.values(value).every(entry => typeof entry === 'string') +} diff --git a/cli/src/services/remove-service.ts b/cli/src/services/remove-service.ts index e3e019f5..7634ad27 100644 --- a/cli/src/services/remove-service.ts +++ b/cli/src/services/remove-service.ts @@ -3,6 +3,7 @@ import { relative, isAbsolute } from 'node:path' import { InventoryStore } from '../stores/inventory-store' import { CliError } from '../shared/errors' import { EXIT } from '../shared/constants' +import { acquireSkillTargetLock } from './skill-target-lock' /** * Validate that child path is strictly under parent directory. @@ -52,8 +53,9 @@ export async function removeLocalSkill(options: RemoveLocalOptions): Promise Promise> = [] - for (const { item, target } of targetsToRemove) { + for (const { target } of targetsToRemove) { // Validate installDir is strictly under the recorded rootDir if (!target.rootDir || !isPathUnder(target.installDir, target.rootDir)) { throw new CliError(`unsafe remove path: ${target.installDir} is not under ${target.rootDir ?? 'unknown root'}`, EXIT.filesystem, { @@ -61,20 +63,31 @@ export async function removeLocalSkill(options: RemoveLocalOptions): Promise left.target.installDir.localeCompare(right.target.installDir))) { + releases.push(await acquireSkillTargetLock(target.rootDir, options.slug)) } - if (existed) { - await rm(target.installDir, { recursive: true }) - } + for (const { item, target } of targetsToRemove) { + let existed = true + try { + await stat(target.installDir) + } catch { + existed = false + } - await store.removeTarget(options.registry, item.namespace, options.slug, target.installDir) - removed.push({ namespace: item.namespace, agent: target.agent, dir: target.installDir, existed }) + if (existed) { + await rm(target.installDir, { recursive: true }) + } + + await store.removeTarget(options.registry, item.namespace, options.slug, target.installDir) + removed.push({ namespace: item.namespace, agent: target.agent, dir: target.installDir, existed }) + } + } finally { + for (const release of releases.reverse()) await release() } return { removed } diff --git a/cli/src/services/skill-target-lock.ts b/cli/src/services/skill-target-lock.ts new file mode 100644 index 00000000..cd32cfd1 --- /dev/null +++ b/cli/src/services/skill-target-lock.ts @@ -0,0 +1,79 @@ +import { createHash } from 'node:crypto' +import { chmod, lstat, mkdir } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { lock } from 'proper-lockfile' +import { canonicalizeExistingPath } from '../platform/paths' +import { CliError } from '../shared/errors' +import { EXIT } from '../shared/constants' + +/** Serializes every local lifecycle mutation for one Skill target directory. */ +export async function acquireSkillTargetLock(rootDir: string, slug: string): Promise<() => Promise> { + const lockPath = await skillTargetLockPath(rootDir, slug) + try { + return await lock(lockPath, { + lockfilePath: lockPath, + realpath: false, + stale: 10_000, + update: 3_000, + retries: 0 + }) + } catch (error) { + if (error instanceof Error && 'code' in error && error.code === 'ELOCKED') { + throw targetBusyError(rootDir, slug) + } + throw error + } +} + +export async function skillTargetLockPath(rootDir: string, slug: string): Promise { + const canonicalRoot = await canonicalizeExistingPath(resolve(rootDir)) + const target = resolve(canonicalRoot, slug) + const digest = createHash('sha256').update(target).digest('hex') + const uid = typeof process.getuid === 'function' ? process.getuid() : 'user' + const lockDir = join(tmpdir(), `skillhub-cli-target-locks-${uid}`) + await ensurePrivateLockDir(lockDir) + return join(lockDir, `${digest}.lock`) +} + +interface LockDirectoryDetails { + isDirectory(): boolean + isSymbolicLink(): boolean + uid: number + mode: number +} + +export function assertPrivateLockDir( + lockDir: string, + details: LockDirectoryDetails, + currentUid: number | null +): void { + if (!details.isDirectory() || details.isSymbolicLink()) { + throw new Error(`unsafe SkillHub CLI lock directory: ${lockDir}`) + } + if (currentUid !== null && details.uid !== currentUid) { + throw new Error(`SkillHub CLI lock directory is owned by another user: ${lockDir}`) + } +} + +export async function ensurePrivateLockDir(lockDir: string): Promise { + try { + await mkdir(lockDir, { mode: 0o700 }) + } catch (error) { + if (!(error instanceof Error && 'code' in error && error.code === 'EEXIST')) throw error + } + + const details = await lstat(lockDir) + const currentUid = typeof process.getuid === 'function' ? process.getuid() : null + assertPrivateLockDir(lockDir, details, currentUid) + if (process.platform !== 'win32' && (details.mode & 0o077) !== 0) { + await chmod(lockDir, 0o700) + } +} + +function targetBusyError(rootDir: string, slug: string): CliError { + return new CliError(`install target is busy: ${join(rootDir, slug)}`, EXIT.filesystem, { + path: join(rootDir, slug), + next: 'wait for the other SkillHub CLI process to finish and retry' + }) +} diff --git a/cli/src/services/skill-version-order.ts b/cli/src/services/skill-version-order.ts new file mode 100644 index 00000000..eb22e70a --- /dev/null +++ b/cli/src/services/skill-version-order.ts @@ -0,0 +1,11 @@ +import { compare as compareSemver, valid as validSemver } from 'semver' + +export type SkillVersionOrder = 'same' | 'remote-newer' | 'remote-older' | 'unknown' + +export function compareSkillVersions(installedVersion: string, remoteVersion: string): SkillVersionOrder { + if (installedVersion === remoteVersion) return 'same' + if (!validSemver(installedVersion) || !validSemver(remoteVersion)) return 'unknown' + const order = compareSemver(remoteVersion, installedVersion) + if (order === 0) return 'same' + return order > 0 ? 'remote-newer' : 'remote-older' +} diff --git a/cli/src/services/sync-service.ts b/cli/src/services/sync-service.ts index ca7377bf..2bab0298 100644 --- a/cli/src/services/sync-service.ts +++ b/cli/src/services/sync-service.ts @@ -7,8 +7,9 @@ import { InventoryStore } from '../stores/inventory-store' import { SyncWorkspaceStore, type NamespaceSyncState } from '../stores/sync-workspace-store' import { createZip, isZipFile } from '../platform/archive' import { pathExists } from '../platform/paths' +import { compareSkillVersions } from './skill-version-order' -export type SyncStatus = 'up-to-date' | 'update-available' | 'local-changed' | 'orphaned' | 'not-installed' +export type SyncStatus = 'up-to-date' | 'update-available' | 'local-changed' | 'blocked' | 'orphaned' | 'not-installed' export interface SkillSyncMetadata { registry: string @@ -36,6 +37,7 @@ export interface PullResult { entries: SyncStatusEntry[] actions: Array<{ slug: string; action: 'installed' | 'updated' | 'pruned' }> failures: Array<{ slug: string; message: string }> + warnings: Array<{ slug: string; message: string }> } export interface PushResultItem { @@ -87,15 +89,46 @@ export async function inspectNamespaceWorkspace(options: { } const snapshot = await snapshotSkillDirectory(skillDir) + const changedFiles = snapshot.fingerprint === metadata.fingerprint + ? [] + : diffSkillFiles(metadata.files, snapshot.files) + const versionOrder = compareSkillVersions(metadata.version, remote.version) + if (versionOrder === 'remote-older') { + entries.push({ + ...baseEntry(remote, 'blocked'), + localVersion: metadata.version, + changedFiles, + reason: 'remote version is older than the installed version; local files were kept' + }) + continue + } + if (versionOrder === 'unknown') { + entries.push({ + ...baseEntry(remote, 'blocked'), + localVersion: metadata.version, + changedFiles, + reason: 'cannot determine version order; use explicit install after verifying the release' + }) + continue + } + if (versionOrder === 'same' && metadata.fingerprint !== remote.fingerprint) { + entries.push({ + ...baseEntry(remote, 'blocked'), + localVersion: metadata.version, + changedFiles, + reason: 'remote content changed without a newer version; use explicit install after verifying the release' + }) + continue + } if (snapshot.fingerprint !== metadata.fingerprint) { entries.push({ ...baseEntry(remote, 'local-changed'), localVersion: metadata.version, - changedFiles: diffSkillFiles(metadata.files, snapshot.files) + changedFiles }) continue } - if (metadata.fingerprint !== remote.fingerprint) { + if (versionOrder === 'remote-newer') { entries.push({ ...baseEntry(remote, 'update-available'), localVersion: metadata.version @@ -133,6 +166,7 @@ export async function pullNamespace(options: { check: boolean prune: boolean force: boolean + installSkillFn?: typeof installSkill }): Promise { const inspected = await inspectNamespaceWorkspace(options) const result: PullResult = { @@ -140,13 +174,17 @@ export async function pullNamespace(options: { rootDir: options.rootDir, entries: inspected.entries, actions: [], - failures: [] + failures: inspected.entries + .filter(entry => entry.status === 'blocked') + .map(entry => ({ slug: entry.slug, message: entry.reason ?? 'automatic sync is blocked' })), + warnings: [] } if (options.check) return result const remoteBySlug = new Map(inspected.remoteItems.map(item => [item.slug, item])) for (const entry of inspected.entries) { if (entry.status === 'up-to-date' || entry.status === 'orphaned') continue + if (entry.status === 'blocked') continue if (entry.status === 'local-changed' && !options.force) { result.failures.push({ slug: entry.slug, message: entry.reason ?? 'local changes detected; pass --force to overwrite' }) continue @@ -154,7 +192,7 @@ export async function pullNamespace(options: { const remote = remoteBySlug.get(entry.slug) if (!remote) continue try { - await installSkill({ + const installed = await (options.installSkillFn ?? installSkill)({ registry: options.registry, token: options.token, namespace: options.namespace, @@ -172,6 +210,7 @@ export async function pullNamespace(options: { force: entry.status !== 'not-installed' || options.force }) result.actions.push({ slug: entry.slug, action: entry.status === 'not-installed' ? 'installed' : 'updated' }) + result.warnings.push(...(installed.warnings ?? []).map(message => ({ slug: entry.slug, message }))) } catch (error) { result.failures.push({ slug: entry.slug, message: error instanceof Error ? error.message : 'install failed' }) } diff --git a/cli/src/services/upgrade-service.ts b/cli/src/services/upgrade-service.ts new file mode 100644 index 00000000..e462c6a1 --- /dev/null +++ b/cli/src/services/upgrade-service.ts @@ -0,0 +1,409 @@ +import { isAbsolute, relative, resolve } from 'node:path' +import { canonicalizeExistingPath, pathExists } from '../platform/paths' +import { SkillHubClient, type ResolveResponse } from '../clients/skillhub-client' +import { InventoryStore, type InventoryItem, type InventoryTarget } from '../stores/inventory-store' +import { CliError } from '../shared/errors' +import { EXIT } from '../shared/constants' +import { hasExplicitNamespace, parseSkillName, resolveSkillName } from '../shared/skill-name-parser' +import { diffSkillFiles, snapshotSkillDirectory } from './skill-fingerprint' +import { installSkill } from './install-service' +import { readInstalledSkillMetadata, sameInstalledSkillSource } from './installed-skill-metadata' +import { compareSkillVersions } from './skill-version-order' + +const MAX_UPGRADE_SELECTION = 50 + +export interface UpgradeSelectionOptions { + coordinates: string[] + namespace?: string | undefined + registry?: string | undefined + agents?: string[] | undefined + dir?: string | undefined + force: boolean + home?: string + tokenForRegistry: (registry: string) => Promise +} + +export type UpgradePlanAction = 'upgrade' | 'unchanged' | 'blocked' + +export interface UpgradePlanItem { + coordinate: string + registry: string + currentVersion: string + remoteVersion?: string + action: UpgradePlanAction + reason?: string + changedFiles: string[] + targets: Array<{ agent: string; dir: string }> + resolved?: ResolveResponse + inventoryItem: InventoryItem + selectedTargets: InventoryTarget[] + expectedTargetFiles: Record> + allowTargetDrift: boolean +} + +export interface UpgradePlan { + items: UpgradePlanItem[] + blocked: number + upgrades: number + unchanged: number +} + +export type UpgradeExecutionAction = 'upgraded' | 'unchanged' | 'failed' | 'not-attempted' + +export interface UpgradeExecutionResult { + items: Array<{ + coordinate: string + action: UpgradeExecutionAction + reason?: string + warnings?: string[] + exitCode?: number + }> + upgraded: number + unchanged: number + failed: number + notAttempted: number +} + +type UpgradeExecutionOptions = Pick & { + installSkillFn?: typeof installSkill +} + +export async function planSkillUpgrades(options: UpgradeSelectionOptions): Promise { + if (options.coordinates.length === 0) { + throw new CliError('provide at least one installed skill coordinate', EXIT.usage) + } + if (options.coordinates.length > MAX_UPGRADE_SELECTION) { + throw new CliError(`upgrade accepts at most ${MAX_UPGRADE_SELECTION} coordinates`, EXIT.usage) + } + + const store = new InventoryStore(options.home) + const inventory = await store.read() + const selected = selectInventoryItems(inventory.items, options) + const items: UpgradePlanItem[] = [] + + for (const selection of selected) { + const coordinate = `@${selection.item.namespace}/${selection.item.slug}` + const base = { + coordinate, + registry: selection.item.registry, + currentVersion: selection.item.version, + changedFiles: [] as string[], + targets: selection.targets.map(target => ({ agent: target.agent, dir: target.installDir })), + inventoryItem: selection.item, + selectedTargets: selection.targets, + expectedTargetFiles: {} as Record>, + allowTargetDrift: options.force + } + + if (selection.targets.length !== selection.item.targets.length) { + items.push({ + ...base, + action: 'blocked', + reason: 'partial-target upgrades are not supported because one inventory item has one shared version' + }) + continue + } + + let resolved: ResolveResponse + try { + const token = await options.tokenForRegistry(selection.item.registry) + resolved = await new SkillHubClient(selection.item.registry, token) + .resolve(selection.item.namespace, selection.item.slug) + } catch (error) { + items.push({ + ...base, + action: 'blocked', + reason: error instanceof Error ? error.message : 'remote version unavailable' + }) + continue + } + + if (resolved.namespace !== selection.item.namespace || resolved.slug !== selection.item.slug) { + items.push({ + ...base, + remoteVersion: resolved.version, + action: 'blocked', + reason: 'registry resolved a different skill identity' + }) + continue + } + + const inspection = await inspectTargets(selection.item, selection.targets) + const hardConflict = inspection.hardConflicts[0] + if (hardConflict) { + items.push({ + ...base, + remoteVersion: resolved.version, + action: 'blocked', + reason: hardConflict, + changedFiles: inspection.changedFiles, + expectedTargetFiles: inspection.currentFiles, + resolved + }) + continue + } + if (inspection.changedFiles.length > 0 && !options.force) { + items.push({ + ...base, + remoteVersion: resolved.version, + action: 'blocked', + reason: 'local changes detected; pass --force to replace same-source files', + changedFiles: inspection.changedFiles, + expectedTargetFiles: inspection.currentFiles, + resolved + }) + continue + } + if (inspection.baselineMissing && !options.force) { + items.push({ + ...base, + remoteVersion: resolved.version, + action: 'blocked', + reason: 'installed metadata has no file baseline; pass --force to migrate this same-source installation', + expectedTargetFiles: inspection.currentFiles, + resolved + }) + continue + } + + const versionOrder = compareSkillVersions(selection.item.version, resolved.version) + if (versionOrder === 'remote-older') { + items.push({ + ...base, + remoteVersion: resolved.version, + action: 'blocked', + reason: 'remote version is older than the installed version; local files were kept', + changedFiles: inspection.changedFiles, + expectedTargetFiles: inspection.currentFiles, + resolved + }) + continue + } + if (versionOrder === 'unknown') { + items.push({ + ...base, + remoteVersion: resolved.version, + action: 'blocked', + reason: 'cannot determine version order; use explicit install after verifying the release', + changedFiles: inspection.changedFiles, + expectedTargetFiles: inspection.currentFiles, + resolved + }) + continue + } + + const unchanged = versionOrder === 'same' && + selection.item.fingerprint === resolved.fingerprint && + inspection.metadataCurrent + if (versionOrder === 'same' && !unchanged) { + items.push({ + ...base, + remoteVersion: resolved.version, + action: 'blocked', + reason: 'remote content changed without a newer version; use explicit install after verifying the release', + changedFiles: inspection.changedFiles, + expectedTargetFiles: inspection.currentFiles, + resolved + }) + continue + } + items.push({ + ...base, + remoteVersion: resolved.version, + action: unchanged ? 'unchanged' : 'upgrade', + changedFiles: inspection.changedFiles, + expectedTargetFiles: inspection.currentFiles, + resolved + }) + } + + return { + items, + blocked: items.filter(item => item.action === 'blocked').length, + upgrades: items.filter(item => item.action === 'upgrade').length, + unchanged: items.filter(item => item.action === 'unchanged').length + } +} + +export async function executeSkillUpgradePlan( + plan: UpgradePlan, + options: UpgradeExecutionOptions +): Promise { + if (plan.blocked > 0) { + throw new CliError('upgrade plan contains blocked skills', EXIT.validation) + } + + const items: UpgradeExecutionResult['items'] = [] + let stopped = false + for (const item of plan.items) { + if (item.action === 'unchanged') { + items.push({ coordinate: item.coordinate, action: 'unchanged' }) + continue + } + if (item.action !== 'upgrade' || !item.resolved) continue + if (stopped) { + items.push({ coordinate: item.coordinate, action: 'not-attempted' }) + continue + } + + try { + const token = await options.tokenForRegistry(item.registry) + const installed = await (options.installSkillFn ?? installSkill)({ + registry: item.registry, + token, + namespace: item.inventoryItem.namespace, + slug: item.inventoryItem.slug, + resolved: item.resolved, + targets: item.selectedTargets.map(target => ({ + agent: target.agent, + rootDir: target.rootDir, + scope: 'project', + source: 'explicit' + })), + force: true, + home: options.home, + expectedTargetFiles: item.expectedTargetFiles, + allowTargetDrift: item.allowTargetDrift, + requireExistingTargets: true + }) + items.push({ + coordinate: item.coordinate, + action: 'upgraded', + ...(installed.warnings?.length ? { warnings: installed.warnings } : {}) + }) + } catch (error) { + items.push({ + coordinate: item.coordinate, + action: 'failed', + reason: error instanceof Error ? error.message : 'unexpected upgrade failure', + ...(error instanceof CliError ? { exitCode: error.exitCode } : {}) + }) + stopped = true + } + } + + return { + items, + upgraded: items.filter(item => item.action === 'upgraded').length, + unchanged: items.filter(item => item.action === 'unchanged').length, + failed: items.filter(item => item.action === 'failed').length, + notAttempted: items.filter(item => item.action === 'not-attempted').length + } +} + +function selectInventoryItems( + items: InventoryItem[], + options: Pick +): Array<{ item: InventoryItem; targets: InventoryTarget[] }> { + const selected = new Map() + + for (const coordinate of options.coordinates) { + const explicitNamespace = hasExplicitNamespace(coordinate) + const parsed = explicitNamespace + ? resolveSkillName(coordinate, options.namespace) + : parseSkillName(coordinate) + const namespace = explicitNamespace ? parsed.namespace : options.namespace + + const matches = items.flatMap(item => { + if (item.slug !== parsed.slug) return [] + if (namespace && item.namespace !== namespace) return [] + if (options.registry && normalizeRegistry(item.registry) !== normalizeRegistry(options.registry)) return [] + const targets = item.targets.filter(target => matchesTargetFilters(target, options.agents, options.dir)) + return targets.length > 0 ? [{ item, targets }] : [] + }) + + if (matches.length === 0) { + throw new CliError(`skill "${coordinate}" is not installed`, EXIT.usage, { + next: `use skillhub install ${coordinate}` + }) + } + if (matches.length > 1) { + throw new CliError(`installed skill "${coordinate}" is ambiguous`, EXIT.usage, { + matches: matches.map(match => `${match.item.registry} @${match.item.namespace}/${match.item.slug}`), + next: 'use a full coordinate and --registry to select one installation source' + }) + } + + const match = matches[0]! + const key = `${normalizeRegistry(match.item.registry)}\u0000${match.item.namespace}\u0000${match.item.slug}` + selected.set(key, match) + } + + if (selected.size > MAX_UPGRADE_SELECTION) { + throw new CliError(`upgrade resolves to at most ${MAX_UPGRADE_SELECTION} skills`, EXIT.usage) + } + return [...selected.values()] +} + +async function inspectTargets(item: InventoryItem, targets: InventoryTarget[]): Promise<{ + hardConflicts: string[] + changedFiles: string[] + baselineMissing: boolean + metadataCurrent: boolean + currentFiles: Record> +}> { + const hardConflicts: string[] = [] + const changedFiles = new Set() + let baselineMissing = false + let metadataCurrent = true + const currentFiles: Record> = {} + + for (const target of targets) { + if (!isAbsolute(target.rootDir) || !isAbsolute(target.installDir)) { + hardConflicts.push(`legacy relative target path is unsafe to upgrade: ${target.installDir}`) + continue + } + const installDir = await canonicalizeExistingPath(target.installDir) + if (!(await pathExists(installDir))) { + hardConflicts.push(`installed target is missing: ${target.installDir}`) + continue + } + const result = await readInstalledSkillMetadata(installDir) + if (result.status !== 'valid') { + hardConflicts.push(`metadata-invalid at ${target.installDir}: ${result.status === 'missing' ? 'missing' : result.reason}`) + continue + } + if (!sameInstalledSkillSource(result.metadata, item)) { + hardConflicts.push(`source-conflict at ${target.installDir}`) + continue + } + if (!result.metadata.files) { + baselineMissing = true + } else { + const snapshot = await snapshotSkillDirectory(installDir) + currentFiles[target.installDir] = snapshot.files + for (const path of diffSkillFiles(result.metadata.files, snapshot.files)) { + changedFiles.add(`${target.installDir}:${path}`) + } + } + if (result.metadata.version !== item.version || result.metadata.fingerprint !== item.fingerprint) { + metadataCurrent = false + } + } + + return { + hardConflicts, + changedFiles: [...changedFiles].sort(), + baselineMissing, + metadataCurrent, + currentFiles + } +} + +function matchesTargetFilters(target: InventoryTarget, agents?: string[], dir?: string): boolean { + if (agents?.length && !agents.includes(target.agent)) return false + if (!dir) return true + const filterPath = resolve(dir) + const installPath = resolve(target.installDir) + const rootPath = resolve(target.rootDir) + return isSameOrWithin(filterPath, installPath) || isSameOrWithin(filterPath, rootPath) +} + +function isSameOrWithin(parent: string, candidate: string): boolean { + const rel = relative(parent, candidate) + return rel === '' || (!rel.startsWith('..') && !rel.startsWith('/') && !rel.startsWith('\\')) +} + +function normalizeRegistry(registry: string): string { + return registry.replace(/\/+$/, '') +} diff --git a/cli/src/stores/inventory-store.ts b/cli/src/stores/inventory-store.ts index 3b004683..8d540744 100644 --- a/cli/src/stores/inventory-store.ts +++ b/cli/src/stores/inventory-store.ts @@ -1,5 +1,6 @@ -import { open, readFile, rename, rm, writeFile } from 'node:fs/promises' +import { readFile, rename, rm, writeFile } from 'node:fs/promises' import { dirname } from 'node:path' +import { lock } from 'proper-lockfile' import { joinPath, userStateDir, ensureDir, pathExists } from '../platform/paths' export interface InventoryTarget { @@ -22,6 +23,13 @@ export interface Inventory { items: InventoryItem[] } +export class InventoryVersionConflictError extends Error { + constructor(readonly retainedTargets: InventoryTarget[]) { + super('partial-target install would create inconsistent versions') + this.name = 'InventoryVersionConflictError' + } +} + export class InventoryStore { readonly path: string @@ -41,76 +49,56 @@ export class InventoryStore { async writeAtomic(inventory: Inventory): Promise { await ensureDir(dirname(this.path)) + let release: (() => Promise) | null = null + try { + release = await this.acquireLock() + await this.writeUnderLock(inventory) + } finally { + if (release) await release().catch(() => {}) + } + } + + private async mutateAtomic(mutate: (inventory: Inventory) => T): Promise { + await ensureDir(dirname(this.path)) + let release: (() => Promise) | null = null + try { + release = await this.acquireLock() + const inventory = await this.read() + const result = mutate(inventory) + await this.writeUnderLock(inventory) + return result + } finally { + if (release) await release().catch(() => {}) + } + } + + private async writeUnderLock(inventory: Inventory): Promise { const payload = JSON.stringify(inventory, null, 2) JSON.parse(payload) - - const lockPath = `${this.path}.lock` const tmpPath = `${this.path}.${process.pid}.${Date.now()}.tmp` - - let lockHandle: Awaited> | null = null try { - // Acquire exclusive lock with retry and stale lock detection - lockHandle = await this.acquireLock(lockPath) - await writeFile(tmpPath, payload) JSON.parse(await readFile(tmpPath, 'utf-8')) await rename(tmpPath, this.path) } finally { - // Clean up temp file if it still exists await rm(tmpPath, { force: true }).catch(() => {}) - - // Release lock - if (lockHandle) { - await lockHandle.close().catch(() => {}) - await rm(lockPath, { force: true }).catch(() => {}) - } } } - private async acquireLock(lockPath: string, maxRetries = 10, retryDelayMs = 100): Promise>> { - for (let attempt = 0; attempt < maxRetries; attempt++) { - try { - // Try to create lock file with PID and timestamp - const lockHandle = await open(lockPath, 'wx') - const lockData = JSON.stringify({ pid: process.pid, timestamp: Date.now() }) - await writeFile(lockPath, lockData) - return lockHandle - } catch (err) { - if (err instanceof Error && 'code' in err && err.code !== 'EEXIST') throw err - - // Lock exists, check if it's stale (older than 30 seconds) - // 30s threshold chosen to balance between: - // - Allowing slow operations to complete (e.g., large inventory writes) - // - Recovering quickly from crashed processes - try { - const lockContent = await readFile(lockPath, 'utf-8') - const lockData = JSON.parse(lockContent) as { pid: number; timestamp: number } - const ageMs = Date.now() - lockData.timestamp - - if (ageMs > 30000) { - // Stale lock detected - verify the process is actually dead - try { - // process.kill(pid, 0) throws if process doesn't exist - process.kill(lockData.pid, 0) - // Process still alive, wait and retry - } catch { - // Process is dead, safe to remove stale lock - await rm(lockPath, { force: true }).catch(() => {}) - continue - } - } - } catch { - // Lock file disappeared or corrupted, retry - continue - } - - // Lock is held by another active process, wait and retry with exponential backoff - if (attempt < maxRetries - 1) { - await new Promise(resolve => setTimeout(resolve, retryDelayMs * Math.pow(2, attempt))) - } + private acquireLock(): Promise<() => Promise> { + return lock(this.path, { + lockfilePath: `${this.path}.lock`, + realpath: false, + stale: 30_000, + update: 10_000, + retries: { + retries: 10, + factor: 2, + minTimeout: 100, + maxTimeout: 1_000, + randomize: true } - } - throw new Error(`Failed to acquire lock after ${maxRetries} attempts`) + }) } async upsertTarget( @@ -121,52 +109,43 @@ export class InventoryStore { target: InventoryTarget, fingerprint?: string ): Promise { - const inventory = await this.read() - const existing = inventory.items.find( - i => i.registry === registry && i.namespace === namespace && i.slug === slug - ) - const item: InventoryItem = existing ?? { registry, namespace, slug, version, targets: [] } - if (!existing) { - inventory.items.push(item) - } - item.version = version - if (fingerprint !== undefined) item.fingerprint = fingerprint - const existingIdx = item.targets.findIndex(t => t.installDir === target.installDir) - if (existingIdx >= 0) { - item.targets[existingIdx] = target - } else { - item.targets.push(target) - } - await this.writeAtomic(inventory) + await this.mutateAtomic(inventory => { + const existing = inventory.items.find( + i => i.registry === registry && i.namespace === namespace && i.slug === slug + ) + const item: InventoryItem = existing ?? { registry, namespace, slug, version, targets: [] } + if (!existing) inventory.items.push(item) + item.version = version + if (fingerprint !== undefined) item.fingerprint = fingerprint + const existingIdx = item.targets.findIndex(t => t.installDir === target.installDir) + if (existingIdx >= 0) item.targets[existingIdx] = target + else item.targets.push(target) + }) } async removeTarget(registry: string, namespace: string, slug: string, installDir: string): Promise { - const inventory = await this.read() - const item = inventory.items.find(i => i.registry === registry && i.namespace === namespace && i.slug === slug) - if (!item) return false - const idx = item.targets.findIndex(t => t.installDir === installDir) - if (idx < 0) return false - item.targets.splice(idx, 1) - if (item.targets.length === 0) { - inventory.items = inventory.items.filter(i => i !== item) - } - await this.writeAtomic(inventory) - return true + return this.mutateAtomic(inventory => { + const item = inventory.items.find(i => i.registry === registry && i.namespace === namespace && i.slug === slug) + if (!item) return false + const idx = item.targets.findIndex(t => t.installDir === installDir) + if (idx < 0) return false + item.targets.splice(idx, 1) + if (item.targets.length === 0) inventory.items = inventory.items.filter(i => i !== item) + return true + }) } async removeTargetsByInstallDir(installDir: string): Promise { - const inventory = await this.read() - let removed = 0 - for (const item of inventory.items) { - const before = item.targets.length - item.targets = item.targets.filter(t => t.installDir !== installDir) - removed += before - item.targets.length - } - if (removed > 0) { + return this.mutateAtomic(inventory => { + let removed = 0 + for (const item of inventory.items) { + const before = item.targets.length + item.targets = item.targets.filter(t => t.installDir !== installDir) + removed += before - item.targets.length + } inventory.items = inventory.items.filter(item => item.targets.length > 0) - await this.writeAtomic(inventory) - } - return removed + return removed + }) } async replaceTargetAtInstallDir( @@ -177,21 +156,60 @@ export class InventoryStore { target: InventoryTarget, fingerprint?: string ): Promise { - const inventory = await this.read() - for (const item of inventory.items) { - item.targets = item.targets.filter(existing => existing.installDir !== target.installDir) - } - inventory.items = inventory.items.filter(item => item.targets.length > 0) + await this.mutateAtomic(inventory => { + for (const item of inventory.items) { + item.targets = item.targets.filter(existing => existing.installDir !== target.installDir) + } + inventory.items = inventory.items.filter(item => item.targets.length > 0) - let item = inventory.items.find(candidate => - candidate.registry === registry && candidate.namespace === namespace && candidate.slug === slug) - if (!item) { - item = { registry, namespace, slug, version, targets: [] } - inventory.items.push(item) - } - item.version = version - if (fingerprint !== undefined) item.fingerprint = fingerprint - item.targets.push(target) - await this.writeAtomic(inventory) + let item = inventory.items.find(candidate => + candidate.registry === registry && candidate.namespace === namespace && candidate.slug === slug) + if (!item) { + item = { registry, namespace, slug, version, targets: [] } + inventory.items.push(item) + } + item.version = version + if (fingerprint !== undefined) item.fingerprint = fingerprint + item.targets.push(target) + }) + } + + async replaceTargetsAtInstallDirs( + registry: string, + namespace: string, + slug: string, + version: string, + targets: InventoryTarget[], + fingerprint?: string, + replacedInstallDirs: string[] = [] + ): Promise { + await this.mutateAtomic(inventory => { + const installDirs = new Set([ + ...targets.map(target => target.installDir), + ...replacedInstallDirs + ]) + const existingItem = inventory.items.find(candidate => + candidate.registry === registry && candidate.namespace === namespace && candidate.slug === slug) + const retainedTargets = existingItem?.targets.filter(target => !installDirs.has(target.installDir)) ?? [] + if (existingItem && retainedTargets.length > 0 && + (existingItem.version !== version || existingItem.fingerprint !== fingerprint)) { + throw new InventoryVersionConflictError(retainedTargets) + } + + for (const item of inventory.items) { + item.targets = item.targets.filter(existing => !installDirs.has(existing.installDir)) + } + inventory.items = inventory.items.filter(item => item.targets.length > 0) + + let item = inventory.items.find(candidate => + candidate.registry === registry && candidate.namespace === namespace && candidate.slug === slug) + if (!item) { + item = { registry, namespace, slug, version, targets: [] } + inventory.items.push(item) + } + item.version = version + if (fingerprint !== undefined) item.fingerprint = fingerprint + item.targets.push(...targets) + }) } } diff --git a/cli/test/helpers/fake-registry.ts b/cli/test/helpers/fake-registry.ts index 6a3b7bce..024bd3a9 100644 --- a/cli/test/helpers/fake-registry.ts +++ b/cli/test/helpers/fake-registry.ts @@ -1,3 +1,6 @@ +import { createHash } from 'node:crypto' +import { unzipSync } from 'fflate' + type FakeHandler = (req: Request) => Response | Promise export function createFakeRegistry(handlers: Record) { @@ -68,12 +71,25 @@ export interface FakeSkill { version?: string /** Numeric version id returned in resolve. Defaults to 1. */ versionId?: number - /** SHA-256 fingerprint string. Defaults to 'deadbeef'. */ + /** SHA-256 fingerprint string. Defaults to the fingerprint of zipBytes. */ fingerprint?: string /** Raw bytes served as the ZIP body. Defaults to a minimal valid ZIP. */ zipBytes?: Uint8Array } +function resolveSkillFingerprint(skill: FakeSkill): string { + if (skill.fingerprint) return skill.fingerprint + const entries = unzipSync(skill.zipBytes ?? MINIMAL_ZIP) + const aggregate = createHash('sha256') + for (const path of Object.keys(entries) + .filter(path => !path.endsWith('/') && path !== '.skillhub' && !path.startsWith('.skillhub/')) + .sort((left, right) => left.localeCompare(right))) { + const fileHash = createHash('sha256').update(entries[path]!).digest('hex') + aggregate.update(`${path}:${fileHash}\n`, 'utf8') + } + return `sha256:${aggregate.digest('hex')}` +} + // Minimal valid ZIP: local file header + end-of-central-directory record with // zero entries. Enough for any consumer that just checks Content-Type / length. const MINIMAL_ZIP = new Uint8Array([ @@ -103,6 +119,7 @@ export interface CapturedPublish { /** Visibility string from the multipart form field. */ visibility: string rejectExistingVersion: boolean + archiveEntries: string[] } export interface CapturedValidate { @@ -203,7 +220,9 @@ export async function startFakeRegistry(options: FakeRegistryOptions = {}) { delete: CapturedDelete | null validate: CapturedValidate | null review: CapturedReview | null - } = { publish: null, resolve: null, delete: null, validate: null, review: null } + resolves: number + downloads: number + } = { publish: null, resolve: null, delete: null, validate: null, review: null, resolves: 0, downloads: 0 } // If any endpoint is configured with 'network' failure mode, we need a real // TCP-level failure. Start a connection-dropping server and return its URL @@ -291,7 +310,7 @@ export async function startFakeRegistry(options: FakeRegistryOptions = {}) { slug: skill.slug, version: skill.version ?? '1.0.0', versionId: skill.versionId ?? index + 1, - fingerprint: skill.fingerprint ?? 'deadbeef', + fingerprint: resolveSkillFingerprint(skill), updatedAt: '2026-08-18T00:00:00Z', visibility: 'NAMESPACE_ONLY', downloadUrl: buildDownloadUrl(baseUrl, namespace, skill.slug, skill.version ?? '1.0.0') @@ -308,6 +327,7 @@ export async function startFakeRegistry(options: FakeRegistryOptions = {}) { // Resolve: GET /api/cli/v1/skills/:namespace/:slug/resolve const resolveMatch = path.match(/^\/api\/cli\/v1\/skills\/([^/]+)\/([^/]+)\/resolve$/) if (resolveMatch && req.method === 'GET') { + state.resolves++ if (options.failures?.resolve) return failureResponse(options.failures.resolve) const namespace = resolveMatch[1]! const slug = resolveMatch[2]! @@ -329,7 +349,7 @@ export async function startFakeRegistry(options: FakeRegistryOptions = {}) { slug, version, versionId: skill.versionId ?? 1, - fingerprint: skill.fingerprint ?? 'deadbeef', + fingerprint: resolveSkillFingerprint(skill), downloadUrl: buildDownloadUrl(baseUrl, namespace, slug, version) } }) @@ -345,6 +365,7 @@ export async function startFakeRegistry(options: FakeRegistryOptions = {}) { if (!skill) { return Response.json({ code: 404, message: 'not found' }, { status: 404 }) } + state.downloads += 1 const bytes = skill.zipBytes ?? MINIMAL_ZIP return new Response(bytes as BodyInit, { status: 200, @@ -364,6 +385,7 @@ export async function startFakeRegistry(options: FakeRegistryOptions = {}) { if (!skill) { return Response.json({ code: 404, message: 'not found' }, { status: 404 }) } + state.downloads += 1 const bytes = skill.zipBytes ?? MINIMAL_ZIP return new Response(bytes as BodyInit, { status: 200, @@ -415,6 +437,7 @@ export async function startFakeRegistry(options: FakeRegistryOptions = {}) { if (fileField instanceof File) { fileName = fileField.name || fileName } + state.validate = { namespace, fileName, @@ -442,7 +465,7 @@ export async function startFakeRegistry(options: FakeRegistryOptions = {}) { const namespace = publishMatch[1]! // Parse multipart form data asynchronously — return a Promise. - return req.formData().then(form => { + return req.formData().then(async form => { const fileField = form.get('file') const visibility = (form.get('visibility') as string | null) ?? 'PUBLIC' @@ -451,13 +474,17 @@ export async function startFakeRegistry(options: FakeRegistryOptions = {}) { if (fileField instanceof File) { fileName = fileField.name || fileName } + const archiveEntries = fileField instanceof File + ? Object.keys(unzipSync(new Uint8Array(await fileField.arrayBuffer()))).sort() + : [] // Record for test assertions. state.publish = { namespace, fileName, visibility, - rejectExistingVersion: form.get('rejectExistingVersion') === 'true' + rejectExistingVersion: form.get('rejectExistingVersion') === 'true', + archiveEntries } return Response.json({ diff --git a/cli/test/helpers/target-lock-worker.ts b/cli/test/helpers/target-lock-worker.ts new file mode 100644 index 00000000..d6d114a9 --- /dev/null +++ b/cli/test/helpers/target-lock-worker.ts @@ -0,0 +1,39 @@ +import { access, writeFile } from 'node:fs/promises' +import { acquireSkillTargetLock } from '../../src/services/skill-target-lock' +import { CliError } from '../../src/shared/errors' + +const [rootDir, slug, readyPath, startPath, acquiredPath, releasePath] = process.argv.slice(2) +if (!rootDir || !slug || !readyPath || !startPath || !acquiredPath || !releasePath) process.exit(5) + +try { + await writeFile(readyPath, 'ready') + let startRequested = false + while (!startRequested) { + try { + await access(startPath) + startRequested = true + } catch { + await new Promise(resolve => setTimeout(resolve, 10)) + } + } + const release = await acquireSkillTargetLock(rootDir, slug) + await writeFile(acquiredPath, 'acquired') + process.stdout.write('acquired\n') + let releaseRequested = false + while (!releaseRequested) { + try { + await access(releasePath) + releaseRequested = true + } catch { + await new Promise(resolve => setTimeout(resolve, 10)) + } + } + await release() + process.exit(0) +} catch (error) { + if (error instanceof CliError) { + process.stderr.write(`${error.message}\n`) + process.exit(error.exitCode) + } + throw error +} diff --git a/cli/test/integration/concurrency.test.ts b/cli/test/integration/concurrency.test.ts index 8490ed23..8304355b 100644 --- a/cli/test/integration/concurrency.test.ts +++ b/cli/test/integration/concurrency.test.ts @@ -9,7 +9,7 @@ * The unit test in test/unit/stores/inventory-store.test.ts pins the * single-process lock recovery; here we cover the cross-process case. */ -import { mkdir, readFile, writeFile } from 'node:fs/promises' +import { access, mkdir, readFile, readdir, utimes } from 'node:fs/promises' import { join } from 'node:path' import { afterEach, describe, expect, test } from 'bun:test' import { zipSync, strToU8 } from 'fflate' @@ -28,17 +28,7 @@ function makeSkillZip(): Uint8Array { } describe('cross-process concurrency on inventory.json', () => { - // KNOWN BUG (documented here, not yet fixed): - // inventory-store.upsertTarget() reads inventory, modifies in memory, - // then writeAtomic() acquires the lock only over the write half. Two - // concurrent installs each read the (empty) inventory, each adds their - // own item, and the second writer overwrites the first — a classic - // lost-update. - // - // When the fix lands (lock spans read+write, or upsertTarget acquires - // the lock first and re-reads), tighten the inventory assertion to - // `expect(slugs).toEqual(['first', 'second'])`. - test('two parallel installs of distinct slugs: filesystem is correct, inventory has at least one (lost-update bug pinned)', async () => { + test('two parallel installs recover the same stale lock and preserve both inventory items', async () => { const env = await createTempHome() registry = await startFakeRegistry({ token: 'sk_ok', @@ -50,6 +40,11 @@ describe('cross-process concurrency on inventory.json', () => { }) await runCli(['login', '--registry', registry.url, '--token', 'sk_ok'], { HOME: env.home, USERPROFILE: env.home }) + const staleLockPath = join(env.home, '.skillhub', 'inventory.json.lock') + await mkdir(staleLockPath) + const staleTime = new Date(Date.now() - 60_000) + await utimes(staleLockPath, staleTime, staleTime) + const dirA = join(env.cwd, 'A') const dirB = join(env.cwd, 'B') await mkdir(dirA, { recursive: true }) @@ -66,9 +61,6 @@ describe('cross-process concurrency on inventory.json', () => { ) ]) - // Both subprocess installs report success — neither errored at the - // protocol level even though the inventory bookkeeping race ate one of - // their inventory writes. expect(r1.exitCode).toBe(0) expect(r2.exitCode).toBe(0) @@ -80,12 +72,10 @@ describe('cross-process concurrency on inventory.json', () => { await readFile(join(env.home, '.skillhub', 'inventory.json'), 'utf-8') ) as { items: Array<{ slug: string }> } const slugs = inv.items.map(i => i.slug).sort() - // Today: at least one slug always lands; under the lost-update race - // both may NOT be there. When the lock widens to cover read+write, - // upgrade this to `toEqual(['first', 'second'])`. - expect(slugs.length).toBeGreaterThanOrEqual(1) - const lastSlug = slugs[slugs.length - 1]! - expect(['first', 'second']).toContain(lastSlug) + expect(slugs).toEqual(['first', 'second']) + await expect(access(staleLockPath)).rejects.toThrow() + expect((await readdir(join(env.home, '.skillhub'))) + .filter(name => name.startsWith('inventory.json.') && name.endsWith('.tmp'))).toEqual([]) }) test('two parallel installs of the same slug to the same dir: exactly one wins, one conflicts', async () => { @@ -111,15 +101,8 @@ describe('cross-process concurrency on inventory.json', () => { ) ]) - // Two valid outcomes: (a) both succeed because the loser's existence - // check ran BEFORE the winner extracted, OR (b) one succeeds and the - // other reports already-installed (EXIT.filesystem). - // Either way, inventory must end up coherent (single item, single - // target — no duplicates). const codes = [r1.exitCode, r2.exitCode].sort((a, b) => a - b) - expect(codes[0]).toBe(0) // at least one succeeded - const otherCode = codes[1]! - expect([0, 4]).toContain(otherCode) // other either succeeded or got conflict + expect(codes).toEqual([0, 4]) const inv = JSON.parse( await readFile(join(env.home, '.skillhub', 'inventory.json'), 'utf-8') @@ -138,14 +121,13 @@ describe('cross-process concurrency on inventory.json', () => { }) await runCli(['login', '--registry', registry.url, '--token', 'sk_ok'], { HOME: env.home, USERPROFILE: env.home }) - // Plant a stale lock file: PID 1 (init, never the same as our test - // child, and won't match the spawned subprocess's PID), with a very - // old timestamp so the store treats it as stale. + // Plant a stale proper-lockfile directory. const skillhubDir = join(env.home, '.skillhub') await mkdir(skillhubDir, { recursive: true }) const lockPath = join(skillhubDir, 'inventory.json.lock') - const ancientTimestamp = Date.now() - 600_000 // 10 minutes ago — past the 30s stale threshold - await writeFile(lockPath, JSON.stringify({ pid: 1, timestamp: ancientTimestamp })) + await mkdir(lockPath) + const staleTime = new Date(Date.now() - 60_000) + await utimes(lockPath, staleTime, staleTime) const installDir = join(env.cwd, 'stale') await mkdir(installDir, { recursive: true }) diff --git a/cli/test/integration/help-command.test.ts b/cli/test/integration/help-command.test.ts index a3132d1e..af082043 100644 --- a/cli/test/integration/help-command.test.ts +++ b/cli/test/integration/help-command.test.ts @@ -34,6 +34,23 @@ describe('help command', () => { expect(result.stdout).toContain('skillhub search') }) + test('distinguishes skill upgrade from CLI self-update and namespace sync', async () => { + const upgrade = await runCli(['help', 'upgrade']) + expect(upgrade.exitCode).toBe(0) + expect(upgrade.stdout).toContain('Upgrade explicitly selected installed skills') + expect(upgrade.stdout).toContain('skillhub upgrade ') + expect(upgrade.stdout).toContain('--check') + expect(upgrade.stdout).toContain('--force') + + const update = await runCli(['help', 'update']) + expect(update.exitCode).toBe(0) + expect(update.stdout).toContain('Check or update CLI itself') + + const sync = await runCli(['help', 'sync']) + expect(sync.exitCode).toBe(0) + expect(sync.stdout).toContain('namespace workspaces') + }) + // P1: bare `skillhub help` (no topic) prints the directory of all commands test('bare help lists all commands in human format', async () => { const result = await runCli(['help']) diff --git a/cli/test/integration/install-command.test.ts b/cli/test/integration/install-command.test.ts index 27b0623c..9687e60a 100644 --- a/cli/test/integration/install-command.test.ts +++ b/cli/test/integration/install-command.test.ts @@ -42,7 +42,6 @@ describe('install command — P0', () => { slug: 'pdf-parser', version: '1.0.0', versionId: 1, - fingerprint: 'abc123', zipBytes: makeSkillZip() } ] diff --git a/cli/test/integration/publish-command.test.ts b/cli/test/integration/publish-command.test.ts index 560e2f33..2e2c4db2 100644 --- a/cli/test/integration/publish-command.test.ts +++ b/cli/test/integration/publish-command.test.ts @@ -1,4 +1,4 @@ -import { mkdtemp, writeFile } from 'node:fs/promises' +import { mkdir, mkdtemp, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, test } from 'bun:test' @@ -117,6 +117,25 @@ describe('publish command — P0', () => { expect(registry.received.publish!.visibility).toBe('PUBLIC') }) + test('directory publish excludes local SkillHub installation metadata', async () => { + const env = await createTempHome() + registry = await startFakeRegistry({ token: 'sk_ok' }) + await login(env, registry.url) + + const dir = await makeTempDir(['SKILL.md', '# Demo']) + await mkdir(join(dir, '.skillhub'), { recursive: true }) + await writeFile(join(dir, '.skillhub', 'metadata.json'), '{"registry":"private"}') + + const result = await runCli(['publish', dir, '--registry', registry.url], { + HOME: env.home, + USERPROFILE: env.home + }) + + expect(result.exitCode).toBe(0) + expect(registry.received.publish!.archiveEntries).toContain('SKILL.md') + expect(registry.received.publish!.archiveEntries.some(path => path.startsWith('.skillhub'))).toBe(false) + }) + test('zip file happy path: exit 0, fileName matches passed file', async () => { const env = await createTempHome() registry = await startFakeRegistry({ token: 'sk_ok' }) @@ -242,7 +261,6 @@ describe('publish command — P1', () => { // P1 — content shape: directory layout and edge files // --------------------------------------------------------------------------- -import { mkdir } from 'node:fs/promises' import { unzipSync, strFromU8 } from 'fflate' describe('publish command — content shape', () => { diff --git a/cli/test/integration/sync-command.test.ts b/cli/test/integration/sync-command.test.ts index a6e97b39..9978cc40 100644 --- a/cli/test/integration/sync-command.test.ts +++ b/cli/test/integration/sync-command.test.ts @@ -6,6 +6,9 @@ import { describe, expect, test } from 'bun:test' import { startFakeRegistry, type FakeSkill } from '../helpers/fake-registry' import { runCli } from '../helpers/run-cli' import { createTempHome } from '../helpers/temp-env' +import { SkillHubClient } from '../../src/clients/skillhub-client' +import { pullNamespace } from '../../src/services/sync-service' +import { renderPullResult } from '../../src/commands/sync' function makeSkill(body: string): { zipBytes: Uint8Array; fingerprint: string } { const content = strToU8(body) @@ -15,6 +18,43 @@ function makeSkill(body: string): { zipBytes: Uint8Array; fingerprint: string } } describe('sync command', () => { + test('pull propagates committed install warnings', async () => { + const env = await createTempHome() + const skillsDir = join(env.cwd, 'team-skills') + const fixture = makeSkill('---\nname: demo\ndescription: Demo\nversion: 1.0.0\n---\n') + const registry = await startFakeRegistry({ + token: 'token', + skills: [{ namespace: 'team-a', slug: 'demo', ...fixture }] + }) + + try { + const result = await pullNamespace({ + client: new SkillHubClient(registry.url, 'token'), + registry: registry.url, + token: 'token', + namespace: 'team-a', + rootDir: skillsDir, + check: false, + prune: false, + force: false, + installSkillFn: async () => ({ + installed: [{ agent: 'workspace', dir: join(skillsDir, 'demo') }], + warnings: ['target lock cleanup failed: simulated release failure'] + }) + }) + + expect(result.actions).toEqual([{ slug: 'demo', action: 'installed' }]) + expect(result.warnings).toEqual([{ + slug: 'demo', + message: 'target lock cleanup failed: simulated release failure' + }]) + expect(JSON.parse(renderPullResult(result, true, false)).warnings).toEqual(result.warnings) + expect(renderPullResult(result, false, false)).toContain('warning demo: target lock cleanup failed') + } finally { + registry.stop() + } + }) + test('pull installs a namespace incrementally and writes workspace metadata', async () => { const env = await createTempHome() const skillsDir = join(env.cwd, 'team-skills') @@ -87,6 +127,230 @@ describe('sync command', () => { } }) + test('reports a newer remote version as update-available even when content is unchanged', async () => { + const env = await createTempHome() + const skillsDir = join(env.cwd, 'team-skills') + const fixture = makeSkill('---\nname: demo\ndescription: Demo\nversion: 1.0.0\n---\n') + const skill: FakeSkill = { + namespace: 'team-a', + slug: 'demo', + version: '1.0.0', + versionId: 1, + ...fixture + } + const registry = await startFakeRegistry({ token: 'token', skills: [skill] }) + + try { + await runCli([ + 'sync', 'pull', '--namespace', 'team-a', '--dir', skillsDir, + '--registry', registry.url, '--token', 'token' + ], { HOME: env.home }, { cwd: env.cwd }) + skill.version = '1.1.0' + skill.versionId = 2 + + const status = await runCli([ + 'sync', 'status', '--namespace', 'team-a', '--dir', skillsDir, + '--registry', registry.url, '--token', 'token', '--json' + ], { HOME: env.home }, { cwd: env.cwd }) + expect(JSON.parse(status.stdout).items[0]).toMatchObject({ + status: 'update-available', + localVersion: '1.0.0', + remoteVersion: '1.1.0' + }) + } finally { + registry.stop() + } + }) + + test('blocks same-version remote content drift even with force', async () => { + const env = await createTempHome() + const skillsDir = join(env.cwd, 'team-skills') + const original = makeSkill('# original\n') + const changed = makeSkill('# changed without a version bump\n') + const skill: FakeSkill = { + namespace: 'team-a', + slug: 'demo', + version: '1.0.0', + versionId: 1, + ...original + } + const registry = await startFakeRegistry({ token: 'token', skills: [skill] }) + + try { + await runCli([ + 'sync', 'pull', '--namespace', 'team-a', '--dir', skillsDir, + '--registry', registry.url, '--token', 'token' + ], { HOME: env.home }, { cwd: env.cwd }) + skill.fingerprint = changed.fingerprint + skill.zipBytes = changed.zipBytes + + const status = await runCli([ + 'sync', 'status', '--namespace', 'team-a', '--dir', skillsDir, + '--registry', registry.url, '--token', 'token', '--json' + ], { HOME: env.home }, { cwd: env.cwd }) + expect(JSON.parse(status.stdout).items[0]).toMatchObject({ + status: 'blocked', + reason: 'remote content changed without a newer version; use explicit install after verifying the release' + }) + + const checked = await runCli([ + 'sync', 'pull', '--namespace', 'team-a', '--dir', skillsDir, '--check', + '--registry', registry.url, '--token', 'token', '--json' + ], { HOME: env.home }, { cwd: env.cwd }) + expect(checked.exitCode).toBe(6) + expect(JSON.parse(checked.stdout)).toMatchObject({ ok: false, check: true }) + + const pulled = await runCli([ + 'sync', 'pull', '--namespace', 'team-a', '--dir', skillsDir, '--force', + '--registry', registry.url, '--token', 'token', '--json' + ], { HOME: env.home }, { cwd: env.cwd }) + expect(pulled.exitCode).toBe(6) + expect(await readFile(join(skillsDir, 'demo', 'SKILL.md'), 'utf8')).toBe('# original\n') + } finally { + registry.stop() + } + }) + + test('blocks automatic downgrade even when remote content is unchanged', async () => { + const env = await createTempHome() + const skillsDir = join(env.cwd, 'team-skills') + const fixture = makeSkill('# stable content\n') + const skill: FakeSkill = { + namespace: 'team-a', + slug: 'demo', + version: '2.0.0', + versionId: 2, + ...fixture + } + const registry = await startFakeRegistry({ token: 'token', skills: [skill] }) + + try { + await runCli([ + 'sync', 'pull', '--namespace', 'team-a', '--dir', skillsDir, + '--registry', registry.url, '--token', 'token' + ], { HOME: env.home }, { cwd: env.cwd }) + skill.version = '1.0.0' + skill.versionId = 1 + + const pulled = await runCli([ + 'sync', 'pull', '--namespace', 'team-a', '--dir', skillsDir, '--force', + '--registry', registry.url, '--token', 'token', '--json' + ], { HOME: env.home }, { cwd: env.cwd }) + expect(pulled.exitCode).toBe(6) + expect(JSON.parse(pulled.stdout).entries[0]).toMatchObject({ + status: 'blocked', + localVersion: '2.0.0', + remoteVersion: '1.0.0', + reason: 'remote version is older than the installed version; local files were kept' + }) + expect(await readFile(join(skillsDir, 'demo', 'SKILL.md'), 'utf8')).toBe('# stable content\n') + } finally { + registry.stop() + } + }) + + test('blocks sync when local and remote versions cannot be ordered', async () => { + const env = await createTempHome() + const skillsDir = join(env.cwd, 'team-skills') + const fixture = makeSkill('# stable content\n') + const skill: FakeSkill = { + namespace: 'team-a', + slug: 'demo', + version: 'release-a', + versionId: 1, + ...fixture + } + const registry = await startFakeRegistry({ token: 'token', skills: [skill] }) + + try { + await runCli([ + 'sync', 'pull', '--namespace', 'team-a', '--dir', skillsDir, + '--registry', registry.url, '--token', 'token' + ], { HOME: env.home }, { cwd: env.cwd }) + skill.version = 'release-b' + skill.versionId = 2 + + const pulled = await runCli([ + 'sync', 'pull', '--namespace', 'team-a', '--dir', skillsDir, '--force', + '--registry', registry.url, '--token', 'token', '--json' + ], { HOME: env.home }, { cwd: env.cwd }) + expect(pulled.exitCode).toBe(6) + expect(JSON.parse(pulled.stdout).entries[0]).toMatchObject({ + status: 'blocked', + localVersion: 'release-a', + remoteVersion: 'release-b', + reason: 'cannot determine version order; use explicit install after verifying the release' + }) + } finally { + registry.stop() + } + }) + + test('hard remote guards cannot be bypassed by local changes and force', async () => { + const original = makeSkill('# original\n') + const variants = [ + { + name: 'downgrade', + initialVersion: '2.0.0', + remoteVersion: '1.0.0', + remote: original, + reason: 'remote version is older than the installed version; local files were kept' + }, + { + name: 'same-version drift', + initialVersion: '1.0.0', + remoteVersion: '1.0.0', + remote: makeSkill('# changed without a version bump\n'), + reason: 'remote content changed without a newer version; use explicit install after verifying the release' + }, + { + name: 'unknown version order', + initialVersion: 'release-a', + remoteVersion: 'release-b', + remote: original, + reason: 'cannot determine version order; use explicit install after verifying the release' + } + ] + + for (const variant of variants) { + const env = await createTempHome() + const skillsDir = join(env.cwd, 'team-skills') + const skill: FakeSkill = { + namespace: 'team-a', + slug: 'demo', + version: variant.initialVersion, + versionId: 1, + ...original + } + const registry = await startFakeRegistry({ token: 'token', skills: [skill] }) + try { + await runCli([ + 'sync', 'pull', '--namespace', 'team-a', '--dir', skillsDir, + '--registry', registry.url, '--token', 'token' + ], { HOME: env.home }, { cwd: env.cwd }) + await writeFile(join(skillsDir, 'demo', 'SKILL.md'), `# local edit before ${variant.name}\n`) + skill.version = variant.remoteVersion + skill.versionId = 2 + skill.fingerprint = variant.remote.fingerprint + skill.zipBytes = variant.remote.zipBytes + + const pulled = await runCli([ + 'sync', 'pull', '--namespace', 'team-a', '--dir', skillsDir, '--force', + '--registry', registry.url, '--token', 'token', '--json' + ], { HOME: env.home }, { cwd: env.cwd }) + const output = JSON.parse(pulled.stdout) + expect(pulled.exitCode, variant.name).toBe(6) + expect(output.entries[0], variant.name).toMatchObject({ status: 'blocked', reason: variant.reason }) + expect(output.entries[0].changedFiles, variant.name).toEqual(['SKILL.md']) + expect(output.actions, variant.name).toEqual([]) + expect(await readFile(join(skillsDir, 'demo', 'SKILL.md'), 'utf8')) + .toBe(`# local edit before ${variant.name}\n`) + } finally { + registry.stop() + } + } + }) + test('prune removes only unchanged managed orphan skills', async () => { const env = await createTempHome() const skillsDir = join(env.cwd, 'team-skills') diff --git a/cli/test/integration/upgrade-command.test.ts b/cli/test/integration/upgrade-command.test.ts new file mode 100644 index 00000000..8df61ff5 --- /dev/null +++ b/cli/test/integration/upgrade-command.test.ts @@ -0,0 +1,727 @@ +import { createHash } from 'node:crypto' +import { access, mkdir, readFile, realpath, rm, writeFile } from 'node:fs/promises' +import { isAbsolute, join } from 'node:path' +import { afterEach, describe, expect, test } from 'bun:test' +import { strToU8, zipSync } from 'fflate' +import { createTempHome } from '../helpers/temp-env' +import { startFakeRegistry } from '../helpers/fake-registry' +import { runCli } from '../helpers/run-cli' +import { executeSkillUpgradePlan, planSkillUpgrades } from '../../src/services/upgrade-service' +import { installSkill } from '../../src/services/install-service' +import { renderUpgradeResult } from '../../src/commands/upgrade' + +let registries: Array>> = [] + +afterEach(() => { + for (const registry of registries) registry.stop() + registries = [] +}) + +function makeSkill(content: string): { fingerprint: string; zipBytes: Uint8Array } { + const bytes = strToU8(content) + const fileHash = createHash('sha256').update(bytes).digest('hex') + return { + fingerprint: `sha256:${createHash('sha256').update(`SKILL.md:${fileHash}\n`).digest('hex')}`, + zipBytes: zipSync({ 'SKILL.md': bytes }) + } +} + +function makeSkillZip(content: string): Uint8Array { + return makeSkill(content).zipBytes +} + +async function exists(path: string): Promise { + try { + await access(path) + return true + } catch { + return false + } +} + +describe('upgrade command', () => { + test('check is side-effect free and execute upgrades an installed skill', async () => { + const env = await createTempHome() + const skill = { + namespace: 'global', + slug: 'skillhub-registry', + version: '1.0.0', + versionId: 1, + ...makeSkill('# v1') + } + const registry = await startFakeRegistry({ skills: [skill] }) + registries.push(registry) + const rootDir = join(env.cwd, 'skills') + await mkdir(rootDir, { recursive: true }) + + const installed = await runCli([ + 'install', '@global/skillhub-registry', '--dir', rootDir, '--registry', registry.url + ], { HOME: env.home, USERPROFILE: env.home }) + expect(installed.exitCode).toBe(0) + + skill.version = '1.1.0' + skill.versionId = 2 + Object.assign(skill, makeSkill('# v2')) + + const inventoryPath = join(env.home, '.skillhub', 'inventory.json') + const metadataPath = join(rootDir, 'skillhub-registry', '.skillhub', 'metadata.json') + const inventoryBeforeCheck = await readFile(inventoryPath, 'utf-8') + const metadataBeforeCheck = await readFile(metadataPath, 'utf-8') + const checked = await runCli([ + 'upgrade', '@global/skillhub-registry', '--registry', registry.url, '--dir', rootDir, '--check', '--json' + ], { HOME: env.home, USERPROFILE: env.home }) + expect(checked.exitCode).toBe(0) + expect(JSON.parse(checked.stdout).items[0]).toMatchObject({ + coordinate: '@global/skillhub-registry', + currentVersion: '1.0.0', + remoteVersion: '1.1.0', + action: 'upgrade' + }) + expect(await readFile(join(rootDir, 'skillhub-registry', 'SKILL.md'), 'utf-8')).toBe('# v1') + expect(registry.received.downloads).toBe(1) + expect(await readFile(inventoryPath, 'utf-8')).toBe(inventoryBeforeCheck) + expect(await readFile(metadataPath, 'utf-8')).toBe(metadataBeforeCheck) + + const checkedAgain = await runCli([ + 'upgrade', '@global/skillhub-registry', '--registry', registry.url, '--dir', rootDir, '--check', '--json' + ], { HOME: env.home, USERPROFILE: env.home }) + expect(checkedAgain.exitCode).toBe(0) + expect(checkedAgain.stdout).toBe(checked.stdout) + expect(await readFile(inventoryPath, 'utf-8')).toBe(inventoryBeforeCheck) + expect(await readFile(metadataPath, 'utf-8')).toBe(metadataBeforeCheck) + expect(registry.received.downloads).toBe(1) + + const upgraded = await runCli([ + 'upgrade', '@global/skillhub-registry', '--registry', registry.url, '--json' + ], { HOME: env.home, USERPROFILE: env.home }) + expect(upgraded.exitCode).toBe(0) + expect(JSON.parse(upgraded.stdout).items[0].action).toBe('upgraded') + expect(await readFile(join(rootDir, 'skillhub-registry', 'SKILL.md'), 'utf-8')).toBe('# v2') + expect(registry.received.downloads).toBe(2) + + const metadata = JSON.parse(await readFile(metadataPath, 'utf-8')) + expect(metadata).toMatchObject({ + schemaVersion: 1, + version: '1.1.0', + versionId: 2, + fingerprint: makeSkill('# v2').fingerprint + }) + expect(Object.keys(metadata.files)).toContain('SKILL.md') + const inventory = JSON.parse(await readFile(inventoryPath, 'utf-8')) + expect(inventory.items[0]).toMatchObject({ version: '1.1.0', fingerprint: makeSkill('# v2').fingerprint }) + }) + + test('local changes block by default and --force replaces only the same source', async () => { + const env = await createTempHome() + const skill = { + namespace: 'team', + slug: 'code-review', + version: '1.0.0', + ...makeSkill('# v1') + } + const registry = await startFakeRegistry({ skills: [skill] }) + registries.push(registry) + const rootDir = join(env.cwd, 'skills') + await mkdir(rootDir, { recursive: true }) + await runCli(['install', '@team/code-review', '--dir', rootDir, '--registry', registry.url], { + HOME: env.home, + USERPROFILE: env.home + }) + + await writeFile(join(rootDir, 'code-review', 'SKILL.md'), '# locally edited') + skill.version = '1.1.0' + Object.assign(skill, makeSkill('# v2')) + + const blocked = await runCli([ + 'upgrade', '@team/code-review', '--registry', registry.url, '--agent', 'custom', '--check', '--json' + ], { HOME: env.home, USERPROFILE: env.home }) + expect(blocked.exitCode).toBe(6) + expect(JSON.parse(blocked.stdout).items[0]).toMatchObject({ action: 'blocked' }) + expect(JSON.parse(blocked.stdout).items[0].reason).toContain('local changes') + expect(await readFile(join(rootDir, 'code-review', 'SKILL.md'), 'utf-8')).toBe('# locally edited') + + const forced = await runCli([ + 'upgrade', '@team/code-review', '--registry', registry.url, '--force', '--json' + ], { HOME: env.home, USERPROFILE: env.home }) + expect(forced.exitCode).toBe(0) + expect(await readFile(join(rootDir, 'code-review', 'SKILL.md'), 'utf-8')).toBe('# v2') + }) + + test('a local edit made after planning is rechecked before replacement', async () => { + const env = await createTempHome() + const skill = { + namespace: 'global', + slug: 'late-edit', + version: '1.0.0', + versionId: 1, + ...makeSkill('# v1') + } + const registry = await startFakeRegistry({ skills: [skill] }) + registries.push(registry) + const rootDir = join(env.cwd, 'skills') + await mkdir(rootDir, { recursive: true }) + await runCli(['install', '@global/late-edit', '--dir', rootDir, '--registry', registry.url], { + HOME: env.home, + USERPROFILE: env.home + }, { cwd: env.cwd }) + + skill.version = '1.1.0' + skill.versionId = 2 + Object.assign(skill, makeSkill('# v2')) + const tokenForRegistry = async () => undefined + const plan = await planSkillUpgrades({ + coordinates: ['@global/late-edit'], + registry: registry.url, + force: false, + home: env.home, + tokenForRegistry + }) + await writeFile(join(rootDir, 'late-edit', 'SKILL.md'), '# edited after planning') + + const result = await executeSkillUpgradePlan(plan, { home: env.home, tokenForRegistry }) + expect(result.items[0]).toMatchObject({ action: 'failed' }) + expect(result.items[0]?.reason).toContain('local changes detected after upgrade planning') + expect(await readFile(join(rootDir, 'late-edit', 'SKILL.md'), 'utf-8')) + .toBe('# edited after planning') + const inventory = JSON.parse(await readFile(join(env.home, '.skillhub', 'inventory.json'), 'utf-8')) + expect(inventory.items[0]).toMatchObject({ version: '1.0.0', fingerprint: makeSkill('# v1').fingerprint }) + }) + + test('a target removed after planning is not recreated by upgrade', async () => { + const env = await createTempHome() + const skill = { + namespace: 'global', + slug: 'removed-late', + version: '1.0.0', + versionId: 1, + ...makeSkill('# v1') + } + const registry = await startFakeRegistry({ skills: [skill] }) + registries.push(registry) + const rootDir = join(env.cwd, 'skills') + await mkdir(rootDir, { recursive: true }) + await runCli(['install', '@global/removed-late', '--dir', rootDir, '--registry', registry.url], { + HOME: env.home, + USERPROFILE: env.home + }, { cwd: env.cwd }) + + skill.version = '1.1.0' + skill.versionId = 2 + Object.assign(skill, makeSkill('# v2')) + const tokenForRegistry = async () => undefined + const plan = await planSkillUpgrades({ + coordinates: ['@global/removed-late'], + registry: registry.url, + force: false, + home: env.home, + tokenForRegistry + }) + const skillDir = join(rootDir, 'removed-late') + await rm(skillDir, { recursive: true }) + + const result = await executeSkillUpgradePlan(plan, { home: env.home, tokenForRegistry }) + expect(result.items[0]).toMatchObject({ action: 'failed' }) + expect(result.items[0]?.reason).toContain('installed target disappeared before upgrade commit') + expect(await exists(skillDir)).toBe(false) + const inventory = JSON.parse(await readFile(join(env.home, '.skillhub', 'inventory.json'), 'utf-8')) + expect(inventory.items[0]).toMatchObject({ version: '1.0.0', fingerprint: makeSkill('# v1').fingerprint }) + }) + + test('new installs persist absolute targets and legacy relative targets are blocked safely', async () => { + const env = await createTempHome() + const skill = { + namespace: 'global', + slug: 'portable', + version: '1.0.0', + versionId: 1, + ...makeSkill('# v1') + } + const registry = await startFakeRegistry({ skills: [skill] }) + registries.push(registry) + const installed = await runCli([ + 'install', '@global/portable', '--dir', 'skills', '--registry', registry.url + ], { HOME: env.home, USERPROFILE: env.home }, { cwd: env.cwd }) + expect(installed.exitCode).toBe(0) + + const inventoryPath = join(env.home, '.skillhub', 'inventory.json') + const inventory = JSON.parse(await readFile(inventoryPath, 'utf-8')) + expect(isAbsolute(inventory.items[0].targets[0].rootDir)).toBe(true) + expect(inventory.items[0].targets[0].installDir) + .toBe(join(inventory.items[0].targets[0].rootDir, 'portable')) + expect(await realpath(inventory.items[0].targets[0].rootDir)) + .toBe(await realpath(join(env.cwd, 'skills'))) + expect(await realpath(inventory.items[0].targets[0].installDir)) + .toBe(await realpath(join(env.cwd, 'skills', 'portable'))) + + inventory.items[0].targets[0].rootDir = 'skills' + inventory.items[0].targets[0].installDir = join('skills', 'portable') + await writeFile(inventoryPath, JSON.stringify(inventory)) + skill.version = '1.1.0' + skill.versionId = 2 + Object.assign(skill, makeSkill('# v2')) + + const otherCwd = join(env.cwd, 'other') + await mkdir(otherCwd, { recursive: true }) + const result = await runCli([ + 'upgrade', '@global/portable', '--registry', registry.url, '--check', '--json' + ], { HOME: env.home, USERPROFILE: env.home }, { cwd: otherCwd }) + expect(result.exitCode).toBe(6) + expect(JSON.parse(result.stdout).items[0].reason).toContain('legacy relative target path') + expect(await readFile(join(env.cwd, 'skills', 'portable', 'SKILL.md'), 'utf-8')).toBe('# v1') + }) + + test('source conflict is a hard block even with --force', async () => { + const env = await createTempHome() + const skill = { + namespace: 'global', + slug: 'demo', + version: '1.0.0', + ...makeSkill('# v1') + } + const registry = await startFakeRegistry({ skills: [skill] }) + registries.push(registry) + const rootDir = join(env.cwd, 'skills') + await mkdir(rootDir, { recursive: true }) + await runCli(['install', '@global/demo', '--dir', rootDir, '--registry', registry.url], { + HOME: env.home, + USERPROFILE: env.home + }) + + const metadataPath = join(rootDir, 'demo', '.skillhub', 'metadata.json') + const metadata = JSON.parse(await readFile(metadataPath, 'utf-8')) + metadata.namespace = 'another-team' + await writeFile(metadataPath, JSON.stringify(metadata)) + skill.version = '2.0.0' + skill.fingerprint = makeSkill('# v2').fingerprint + + const result = await runCli([ + 'upgrade', '@global/demo', '--registry', registry.url, '--force', '--json' + ], { HOME: env.home, USERPROFILE: env.home }) + expect(result.exitCode).toBe(6) + expect(JSON.parse(result.stdout).items[0].reason).toContain('source-conflict') + expect(await readFile(join(rootDir, 'demo', 'SKILL.md'), 'utf-8')).toBe('# v1') + }) + + test('a bare slug must identify exactly one installed source', async () => { + const env = await createTempHome() + const skillA = { namespace: 'team-a', slug: 'demo', version: '1.0.0', ...makeSkill('# A') } + const skillB = { namespace: 'team-b', slug: 'demo', version: '1.0.0', ...makeSkill('# B') } + const registryA = await startFakeRegistry({ skills: [skillA] }) + const registryB = await startFakeRegistry({ skills: [skillB] }) + registries.push(registryA, registryB) + const rootA = join(env.cwd, 'a') + const rootB = join(env.cwd, 'b') + await mkdir(rootA, { recursive: true }) + await mkdir(rootB, { recursive: true }) + await runCli(['install', '@team-a/demo', '--dir', rootA, '--registry', registryA.url], { + HOME: env.home, + USERPROFILE: env.home + }) + await runCli(['install', '@team-b/demo', '--dir', rootB, '--registry', registryB.url], { + HOME: env.home, + USERPROFILE: env.home + }) + + const ambiguous = await runCli(['upgrade', 'demo', '--check'], { + HOME: env.home, + USERPROFILE: env.home + }) + expect(ambiguous.exitCode).toBe(5) + expect(ambiguous.stderr).toContain('ambiguous') + + const selected = await runCli(['upgrade', 'demo', '--namespace', 'team-a', '--registry', registryA.url, '--check'], { + HOME: env.home, + USERPROFILE: env.home + }) + expect(selected.exitCode).toBe(0) + expect(selected.stdout).toContain('@team-a/demo') + + const fullCoordinate = await runCli(['upgrade', '@team-a/demo', '--check'], { + HOME: env.home, + USERPROFILE: env.home + }) + expect(fullCoordinate.exitCode).toBe(0) + expect(fullCoordinate.stdout).toContain('@team-a/demo') + expect(fullCoordinate.stdout).not.toContain('@team-b/demo') + + const noNamespaceMatch = await runCli(['upgrade', 'demo', '--namespace', 'missing', '--check'], { + HOME: env.home, + USERPROFILE: env.home + }) + expect(noNamespaceMatch.exitCode).toBe(5) + expect(noNamespaceMatch.stderr).toContain('not installed') + }) + + test('target filters select deterministically and missing matches never install', async () => { + const env = await createTempHome() + const skill = { namespace: 'global', slug: 'filtered', version: '1.0.0', ...makeSkill('# v1') } + const registry = await startFakeRegistry({ skills: [skill] }) + registries.push(registry) + const rootDir = join(env.cwd, 'skills') + await mkdir(rootDir, { recursive: true }) + await runCli(['install', '@global/filtered', '--dir', rootDir, '--registry', registry.url], { + HOME: env.home, + USERPROFILE: env.home + }) + + for (const args of [ + ['--dir', rootDir], + ['--agent', 'custom'], + ['--namespace', 'global'], + ['--registry', registry.url] + ]) { + const result = await runCli(['upgrade', 'filtered', ...args, '--force', '--check'], { + HOME: env.home, + USERPROFILE: env.home + }) + expect(result.exitCode).toBe(0) + } + + for (const args of [ + ['--dir', join(env.cwd, 'missing')], + ['--agent', 'codex'], + ['--namespace', 'missing'], + ['--registry', 'http://unmatched.invalid'] + ]) { + const result = await runCli(['upgrade', 'filtered', ...args, '--check'], { + HOME: env.home, + USERPROFILE: env.home + }) + expect(result.exitCode).toBe(5) + expect(result.stderr).toContain('not installed') + } + expect(registry.received.downloads).toBe(1) + }) + + test('one resolved archive is reused for every managed target', async () => { + const env = await createTempHome() + const skill = { + namespace: 'global', + slug: 'shared', + version: '1.0.0', + ...makeSkill('# v1') + } + const registry = await startFakeRegistry({ skills: [skill] }) + registries.push(registry) + + const installed = await runCli([ + 'install', '@global/shared', '--agent', 'codex', '--agent', 'claude-code', '--registry', registry.url + ], { HOME: env.home, USERPROFILE: env.home }, { cwd: env.cwd }) + expect(installed.exitCode).toBe(0) + expect(registry.received.resolves).toBe(1) + expect(registry.received.downloads).toBe(1) + + skill.version = '1.1.0' + Object.assign(skill, makeSkill('# v2')) + + const partial = await runCli([ + 'upgrade', '@global/shared', '--registry', registry.url, '--agent', 'codex', '--check', '--json' + ], { HOME: env.home, USERPROFILE: env.home }, { cwd: env.cwd }) + expect(partial.exitCode).toBe(6) + expect(JSON.parse(partial.stdout).items[0].reason).toContain('partial-target') + expect(registry.received.downloads).toBe(1) + + const upgraded = await runCli(['upgrade', '@global/shared', '--registry', registry.url], { + HOME: env.home, + USERPROFILE: env.home + }, { cwd: env.cwd }) + expect(upgraded.exitCode).toBe(0) + expect(registry.received.resolves).toBe(2) + expect(registry.received.downloads).toBe(2) + expect(await readFile(join(env.home, '.codex', 'skills', 'shared', 'SKILL.md'), 'utf-8')).toBe('# v2') + expect(await readFile(join(env.home, '.claude', 'skills', 'shared', 'SKILL.md'), 'utf-8')).toBe('# v2') + const inventory = JSON.parse(await readFile(join(env.home, '.skillhub', 'inventory.json'), 'utf-8')) + expect(inventory.items[0]).toMatchObject({ version: '1.1.0', fingerprint: makeSkill('# v2').fingerprint }) + expect(inventory.items[0].targets).toHaveLength(2) + + const listed = await runCli(['list', '--json', '--registry', registry.url], { + HOME: env.home, + USERPROFILE: env.home + }) + expect(listed.exitCode).toBe(0) + expect(JSON.parse(listed.stdout).items[0]).toMatchObject({ version: '1.1.0' }) + }) + + test('never downgrades when the registry latest version moves backwards', async () => { + const env = await createTempHome() + const skill = { + namespace: 'global', + slug: 'stable', + version: '2.0.0', + versionId: 2, + ...makeSkill('# v2') + } + const registry = await startFakeRegistry({ skills: [skill] }) + registries.push(registry) + const rootDir = join(env.cwd, 'skills') + await mkdir(rootDir, { recursive: true }) + await runCli(['install', '@global/stable', '--dir', rootDir, '--registry', registry.url], { + HOME: env.home, + USERPROFILE: env.home + }) + + skill.version = '1.0.0' + skill.versionId = 1 + Object.assign(skill, makeSkill('# v1')) + + const result = await runCli([ + 'upgrade', '@global/stable', '--registry', registry.url, '--force', '--json' + ], { HOME: env.home, USERPROFILE: env.home }) + expect(result.exitCode).toBe(6) + expect(JSON.parse(result.stdout).items[0].reason).toContain('older') + expect(await readFile(join(rootDir, 'stable', 'SKILL.md'), 'utf-8')).toBe('# v2') + expect(registry.received.downloads).toBe(1) + }) + + test('keeps local files when resolve is unavailable or same-version content drifts', async () => { + const env = await createTempHome() + const failures: { resolve?: 'server_error' } = {} + const skill = { namespace: 'global', slug: 'resilient', version: '1.0.0', ...makeSkill('# v1') } + const registry = await startFakeRegistry({ skills: [skill], failures }) + registries.push(registry) + const rootDir = join(env.cwd, 'skills') + await mkdir(rootDir, { recursive: true }) + await runCli(['install', '@global/resilient', '--dir', rootDir, '--registry', registry.url], { + HOME: env.home, + USERPROFILE: env.home + }) + + failures.resolve = 'server_error' + const unavailable = await runCli([ + 'upgrade', '@global/resilient', '--registry', registry.url, '--force', '--json' + ], { HOME: env.home, USERPROFILE: env.home }) + expect(unavailable.exitCode).toBe(6) + expect(JSON.parse(unavailable.stdout).items[0].action).toBe('blocked') + expect(await readFile(join(rootDir, 'resilient', 'SKILL.md'), 'utf-8')).toBe('# v1') + + delete failures.resolve + Object.assign(skill, makeSkill('# changed without version bump')) + const drifted = await runCli([ + 'upgrade', '@global/resilient', '--registry', registry.url, '--force', '--json' + ], { HOME: env.home, USERPROFILE: env.home }) + expect(drifted.exitCode).toBe(6) + expect(JSON.parse(drifted.stdout).items[0].reason).toContain('without a newer version') + expect(await readFile(join(rootDir, 'resilient', 'SKILL.md'), 'utf-8')).toBe('# v1') + expect(registry.received.downloads).toBe(1) + }) + + test('a blocked batch reports a plan and does not claim successful writes', async () => { + const env = await createTempHome() + const first = { namespace: 'global', slug: 'first', version: '1.0.0', ...makeSkill('# first v1') } + const second = { namespace: 'global', slug: 'second', version: '1.0.0', ...makeSkill('# second v1') } + const registry = await startFakeRegistry({ skills: [first, second] }) + registries.push(registry) + const rootDir = join(env.cwd, 'skills') + await mkdir(rootDir, { recursive: true }) + for (const slug of ['first', 'second']) { + await runCli(['install', `@global/${slug}`, '--dir', rootDir, '--registry', registry.url], { + HOME: env.home, + USERPROFILE: env.home + }) + } + + first.version = '1.1.0' + Object.assign(first, makeSkill('# first v2')) + second.version = '1.1.0' + Object.assign(second, makeSkill('# second v2')) + await writeFile(join(rootDir, 'second', 'SKILL.md'), '# local change') + + const result = await runCli([ + 'upgrade', '@global/first', '@global/second', '--registry', registry.url, '--json' + ], { HOME: env.home, USERPROFILE: env.home }) + expect(result.exitCode).toBe(6) + const output = JSON.parse(result.stdout) + expect(output.items.find((item: { coordinate: string }) => item.coordinate.endsWith('/first')).action).toBe('upgrade') + expect(output.items.find((item: { coordinate: string }) => item.coordinate.endsWith('/second')).action).toBe('blocked') + expect(await readFile(join(rootDir, 'first', 'SKILL.md'), 'utf-8')).toBe('# first v1') + }) + + test('a runtime batch failure reports committed, failed, and unattempted skills', async () => { + const env = await createTempHome() + const first = { namespace: 'global', slug: 'first', version: '1.0.0', ...makeSkill('# first v1') } + const second = { namespace: 'global', slug: 'second', version: '1.0.0', ...makeSkill('# second v1') } + const third = { namespace: 'global', slug: 'third', version: '1.0.0', ...makeSkill('# third v1') } + const registry = await startFakeRegistry({ skills: [first, second, third] }) + registries.push(registry) + const rootDir = join(env.cwd, 'skills') + await mkdir(rootDir, { recursive: true }) + for (const slug of ['first', 'second', 'third']) { + await runCli(['install', `@global/${slug}`, '--dir', rootDir, '--registry', registry.url], { + HOME: env.home, + USERPROFILE: env.home + }) + } + + first.version = '1.1.0' + Object.assign(first, makeSkill('# first v2')) + second.version = '1.1.0' + second.fingerprint = makeSkill('# second v2').fingerprint + second.zipBytes = strToU8('not a zip archive') + third.version = '1.1.0' + Object.assign(third, makeSkill('# third v2')) + + const result = await runCli([ + 'upgrade', '@global/first', '@global/second', '@global/third', + '--registry', registry.url, '--json' + ], { HOME: env.home, USERPROFILE: env.home }) + expect(result.exitCode).toBe(1) + const output = JSON.parse(result.stdout) + expect(output.summary).toEqual({ upgraded: 1, unchanged: 0, failed: 1, notAttempted: 1 }) + expect(output.items.map((item: { action: string }) => item.action)) + .toEqual(['upgraded', 'failed', 'not-attempted']) + expect(await readFile(join(rootDir, 'first', 'SKILL.md'), 'utf-8')).toBe('# first v2') + expect(await readFile(join(rootDir, 'second', 'SKILL.md'), 'utf-8')).toBe('# second v1') + expect(await readFile(join(rootDir, 'third', 'SKILL.md'), 'utf-8')).toBe('# third v1') + const inventory = JSON.parse(await readFile(join(env.home, '.skillhub', 'inventory.json'), 'utf-8')) + expect(inventory.items.find((item: { slug: string }) => item.slug === 'first').version).toBe('1.1.0') + expect(inventory.items.find((item: { slug: string }) => item.slug === 'second').version).toBe('1.0.0') + expect(inventory.items.find((item: { slug: string }) => item.slug === 'third').version).toBe('1.0.0') + expect(registry.received.downloads).toBe(5) + }) + + test('a committed upgrade keeps success and renders a post-commit warning', async () => { + const env = await createTempHome() + const skill = { namespace: 'global', slug: 'warned', version: '1.0.0', ...makeSkill('# v1') } + const registry = await startFakeRegistry({ skills: [skill] }) + registries.push(registry) + const rootDir = join(env.cwd, 'skills') + await mkdir(rootDir, { recursive: true }) + await runCli(['install', '@global/warned', '--dir', rootDir, '--registry', registry.url], { + HOME: env.home, + USERPROFILE: env.home + }) + skill.version = '1.1.0' + Object.assign(skill, makeSkill('# v2')) + const tokenForRegistry = async () => undefined + const plan = await planSkillUpgrades({ + coordinates: ['@global/warned'], + registry: registry.url, + force: false, + home: env.home, + tokenForRegistry + }) + + const result = await executeSkillUpgradePlan(plan, { + home: env.home, + tokenForRegistry, + installSkillFn: options => installSkill({ + ...options, + acquireTargetLock: async () => async () => { throw new Error('simulated release failure') } + }) + }) + + expect(result).toMatchObject({ upgraded: 1, failed: 0 }) + expect(result.items[0]).toMatchObject({ action: 'upgraded' }) + expect(result.items[0]?.warnings).toEqual(['target lock cleanup failed: simulated release failure']) + expect(JSON.parse(renderUpgradeResult(plan, result, true)).items[0].warnings).toHaveLength(1) + expect(renderUpgradeResult(plan, result, false)).toContain('upgraded') + expect(renderUpgradeResult(plan, result, false)).toContain('[warning: target lock cleanup failed') + expect(await readFile(join(rootDir, 'warned', 'SKILL.md'), 'utf-8')).toBe('# v2') + const inventory = JSON.parse(await readFile(join(env.home, '.skillhub', 'inventory.json'), 'utf-8')) + expect(inventory.items[0]).toMatchObject({ version: '1.1.0', fingerprint: makeSkill('# v2').fingerprint }) + }) + + test('legacy metadata without a file baseline requires explicit force migration', async () => { + const env = await createTempHome() + const skill = { namespace: 'global', slug: 'legacy', version: '1.0.0', ...makeSkill('# v1') } + const registry = await startFakeRegistry({ skills: [skill] }) + registries.push(registry) + const rootDir = join(env.cwd, 'skills') + await mkdir(rootDir, { recursive: true }) + await runCli(['install', '@global/legacy', '--dir', rootDir, '--registry', registry.url], { + HOME: env.home, + USERPROFILE: env.home + }) + const metadataPath = join(rootDir, 'legacy', '.skillhub', 'metadata.json') + const metadata = JSON.parse(await readFile(metadataPath, 'utf-8')) + delete metadata.files + delete metadata.schemaVersion + await writeFile(metadataPath, JSON.stringify(metadata)) + skill.version = '1.1.0' + Object.assign(skill, makeSkill('# v2')) + + const blocked = await runCli([ + 'upgrade', '@global/legacy', '--registry', registry.url, '--check', '--json' + ], { HOME: env.home, USERPROFILE: env.home }) + expect(blocked.exitCode).toBe(6) + expect(JSON.parse(blocked.stdout).items[0].reason).toContain('no file baseline') + + const migrated = await runCli([ + 'upgrade', '@global/legacy', '--registry', registry.url, '--force', '--json' + ], { HOME: env.home, USERPROFILE: env.home }) + expect(migrated.exitCode).toBe(0) + expect(await readFile(join(rootDir, 'legacy', 'SKILL.md'), 'utf-8')).toBe('# v2') + const migratedMetadata = JSON.parse(await readFile(metadataPath, 'utf-8')) + expect(migratedMetadata.schemaVersion).toBe(1) + expect(Object.keys(migratedMetadata.files)).toContain('SKILL.md') + }) + + test('never installs a missing skill and never offers an implicit upgrade-all', async () => { + const env = await createTempHome() + const missing = await runCli(['upgrade', '@global/missing', '--check'], { + HOME: env.home, + USERPROFILE: env.home + }) + expect(missing.exitCode).toBe(5) + expect(missing.stderr).toContain('use skillhub install') + + const empty = await runCli(['upgrade'], { HOME: env.home, USERPROFILE: env.home }) + expect(empty.exitCode).toBe(5) + expect(empty.stderr).toContain('at least one') + + const tooMany = await runCli([ + 'upgrade', ...Array.from({ length: 51 }, (_, index) => `@global/skill-${index}`) + ], { HOME: env.home, USERPROFILE: env.home }) + expect(tooMany.exitCode).toBe(5) + expect(tooMany.stderr).toContain('at most 50') + }) + + test('accepts exactly fifty explicitly installed coordinates', async () => { + const env = await createTempHome() + const skills = Array.from({ length: 50 }, (_, index) => ({ + namespace: 'global', + slug: `skill-${index}`, + version: '1.0.0', + fingerprint: `fp-${index}`, + zipBytes: makeSkillZip(`# skill ${index}`) + })) + const registry = await startFakeRegistry({ skills }) + registries.push(registry) + const rootDir = join(env.cwd, 'skills') + await mkdir(rootDir, { recursive: true }) + const items = [] + for (const skill of skills) { + const skillDir = join(rootDir, skill.slug) + await mkdir(join(skillDir, '.skillhub'), { recursive: true }) + await writeFile(join(skillDir, 'SKILL.md'), `# ${skill.slug}`) + await writeFile(join(skillDir, '.skillhub', 'metadata.json'), JSON.stringify({ + registry: registry.url, + namespace: skill.namespace, + slug: skill.slug, + version: skill.version, + fingerprint: skill.fingerprint, + source: 'skillhub' + })) + items.push({ + registry: registry.url, + namespace: skill.namespace, + slug: skill.slug, + version: skill.version, + fingerprint: skill.fingerprint, + targets: [{ + agent: 'custom', rootDir, installDir: skillDir, installedAt: '2026-09-01T00:00:00Z' + }] + }) + } + await mkdir(join(env.home, '.skillhub'), { recursive: true }) + await writeFile(join(env.home, '.skillhub', 'inventory.json'), JSON.stringify({ items })) + + const result = await runCli([ + 'upgrade', ...skills.map(skill => `@global/${skill.slug}`), + '--registry', registry.url, '--force', '--check', '--json' + ], { HOME: env.home, USERPROFILE: env.home }) + expect(result.exitCode).toBe(0) + expect(JSON.parse(result.stdout).summary).toMatchObject({ unchanged: 50, blocked: 0 }) + expect(registry.received.downloads).toBe(0) + }) +}) diff --git a/cli/test/integration/version-upgrade-flow.test.ts b/cli/test/integration/version-upgrade-flow.test.ts index 75cc761e..56705f61 100644 --- a/cli/test/integration/version-upgrade-flow.test.ts +++ b/cli/test/integration/version-upgrade-flow.test.ts @@ -37,23 +37,25 @@ describe('version upgrade flow', () => { // ------------------------------------------------------------------------- // VU1 — full upgrade lifecycle: // 1. Registry serves pdf-parser@1.0.0 → install → metadata=v1, content=v1 - // 2. Stop registry, start a new one serving pdf-parser@2.0.0 - // 3. Install --force using the new registry URL + // 2. The same registry serves pdf-parser@2.0.0 + // 3. Install --force from the same source identity // 4. metadata.json, inventory.json AND on-disk SKILL.md all reflect v2 // ------------------------------------------------------------------------- test('VU1 install v1 then upgrade to v2 with --force replaces metadata, inventory, and content', async () => { const env = await createTempHome() // --- Stage 1: install v1 ---------------------------------------------- + const skill = { + namespace: 'global', + slug: 'pdf-parser', + version: '1.0.0', + versionId: 1, + zipBytes: makeSkillZipWithBody('# pdf-parser v1\n\nVersion one body.') + } registry = await startFakeRegistry({ token: 'sk_ok', user: { handle: 'u1', displayName: 'User One' }, - skills: [{ - namespace: 'global', - slug: 'pdf-parser', - version: '1.0.0', - zipBytes: makeSkillZipWithBody('# pdf-parser v1\n\nVersion one body.') - }] + skills: [skill] }) await runCli(['login', '--registry', registry.url, '--token', 'sk_ok'], { HOME: env.home, USERPROFILE: env.home }) @@ -77,22 +79,10 @@ describe('version upgrade flow', () => { expect(body).toContain('Version one body.') } - // --- Stage 2: swap registry to v2 ------------------------------------- - registry.stop() - registry = await startFakeRegistry({ - token: 'sk_ok', - user: { handle: 'u1', displayName: 'User One' }, - skills: [{ - namespace: 'global', - slug: 'pdf-parser', - version: '2.0.0', - zipBytes: makeSkillZipWithBody('# pdf-parser v2\n\nVersion two body.') - }] - }) - - // Re-login against the new registry (URL changed, so credentials are - // keyed differently). - await runCli(['login', '--registry', registry.url, '--token', 'sk_ok'], { HOME: env.home, USERPROFILE: env.home }) + // --- Stage 2: publish v2 from the same source -------------------------- + skill.version = '2.0.0' + skill.versionId = 2 + skill.zipBytes = makeSkillZipWithBody('# pdf-parser v2\n\nVersion two body.') const r2 = await runCli( ['install', 'pdf-parser', '--dir', installDir, '--registry', registry.url, '--token', 'sk_ok', '--force'], diff --git a/cli/test/unit/commands/install-command.test.ts b/cli/test/unit/commands/install-command.test.ts index 49911588..8d980610 100644 --- a/cli/test/unit/commands/install-command.test.ts +++ b/cli/test/unit/commands/install-command.test.ts @@ -146,6 +146,25 @@ describe('installCommand dependency injection', () => { }] as AgentCandidate[] } + test('renders post-commit warnings in human and JSON output', async () => { + const deps: InstallCommandDeps = { + isTTY: () => false, + resolveInstallTargets: fakeResolveInstallTargets(), + installSkill: async () => ({ + installed: [{ agent: 'codex', dir: '/home/u/.codex/skills/foo' }], + warnings: ['target lock cleanup failed: simulated release failure'] + }) + } + + const human = await installCommand('@global/foo', { registry: 'http://localhost' }, deps) + expect(human).toContain('Warning: target lock cleanup failed') + const json = JSON.parse(await installCommand('@global/foo', { + registry: 'http://localhost', + json: true + }, deps)) + expect(json.warnings).toEqual(['target lock cleanup failed: simulated release failure']) + }) + test('passes a namespaced coordinate to installSkill', async () => { let received: Parameters>[0] | undefined const deps: InstallCommandDeps = { diff --git a/cli/test/unit/services/install-service.test.ts b/cli/test/unit/services/install-service.test.ts index 95525d5f..a88061e4 100644 --- a/cli/test/unit/services/install-service.test.ts +++ b/cli/test/unit/services/install-service.test.ts @@ -1,9 +1,14 @@ -import { access, mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises' +import { createHash } from 'node:crypto' +import { access, mkdir, mkdtemp, readFile, readdir, rm, symlink, utimes, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, test } from 'bun:test' import { zipSync } from 'fflate' import { installSkill } from '../../../src/services/install-service' +import { planSkillUpgrades } from '../../../src/services/upgrade-service' +import { removeLocalSkill } from '../../../src/services/remove-service' +import { skillTargetLockPath } from '../../../src/services/skill-target-lock' +import { EXIT } from '../../../src/shared/constants' const originalFetch = globalThis.fetch @@ -16,7 +21,16 @@ async function exists(path: string): Promise { } } -function installFetch(zipEntries: Record): typeof fetch { +function skillFingerprint(zipEntries: Record): string { + const aggregate = createHash('sha256') + for (const [name, content] of Object.entries(zipEntries).sort(([left], [right]) => left.localeCompare(right))) { + const fileHash = createHash('sha256').update(content).digest('hex') + aggregate.update(`${name}:${fileHash}\n`, 'utf8') + } + return `sha256:${aggregate.digest('hex')}` +} + +function installFetch(zipEntries: Record, fingerprint = skillFingerprint(zipEntries)): typeof fetch { const archive = zipSync(Object.fromEntries( Object.entries(zipEntries).map(([name, content]) => [name, new TextEncoder().encode(content)]) )) @@ -24,10 +38,10 @@ function installFetch(zipEntries: Record): typeof fetch { return installFetchWithDownloadResponse(new Response( archive.buffer.slice(archive.byteOffset, archive.byteOffset + archive.byteLength) as ArrayBuffer, { status: 200 } - )) + ), fingerprint) } -function installFetchWithDownloadResponse(downloadResponse: Response): typeof fetch { +function installFetchWithDownloadResponse(downloadResponse: Response, fingerprint = 'fp'): typeof fetch { const fakeFetch = async (input: URL | RequestInfo) => { const path = new URL(String(input)).pathname if (path.endsWith('/resolve')) { @@ -38,7 +52,7 @@ function installFetchWithDownloadResponse(downloadResponse: Response): typeof fe slug: 'demo', version: '1.0.0', versionId: 1, - fingerprint: 'fp', + fingerprint, downloadUrl: '/download' } }) @@ -51,6 +65,22 @@ function installFetchWithDownloadResponse(downloadResponse: Response): typeof fe return fakeFetch as unknown as typeof fetch } +async function writeManagedMetadata( + skillDir: string, + identity: { registry: string; namespace: string; slug: string } = { + registry: 'http://registry.test', + namespace: 'global', + slug: 'demo' + } +): Promise { + await mkdir(join(skillDir, '.skillhub'), { recursive: true }) + await writeFile(join(skillDir, '.skillhub', 'metadata.json'), JSON.stringify({ + ...identity, + version: '0.1.0', + source: 'skillhub' + })) +} + describe('installSkill', () => { afterEach(() => { globalThis.fetch = originalFetch @@ -97,6 +127,49 @@ describe('installSkill', () => { expect(await exists(join(home, '.skillhub', 'inventory.json'))).toBe(false) }) + test('rejects a downloaded fingerprint mismatch before replacing files or inventory', async () => { + globalThis.fetch = installFetch({ 'SKILL.md': '# Unexpected' }, 'sha256:unexpected') + const home = await mkdtemp(join(tmpdir(), 'skillhub-install-home-')) + const rootDir = await mkdtemp(join(tmpdir(), 'skillhub-install-root-')) + const skillDir = join(rootDir, 'demo') + const inventoryPath = join(home, '.skillhub', 'inventory.json') + await mkdir(skillDir, { recursive: true }) + await writeFile(join(skillDir, 'SKILL.md'), '# Existing') + await writeManagedMetadata(skillDir) + await mkdir(join(home, '.skillhub'), { recursive: true }) + const inventoryBefore = JSON.stringify({ + items: [{ + registry: 'http://registry.test', + namespace: 'global', + slug: 'demo', + version: '0.1.0', + targets: [{ + agent: 'codex', + rootDir, + installDir: skillDir, + installedAt: '2026-09-01T00:00:00Z' + }] + }] + }) + await writeFile(inventoryPath, inventoryBefore) + + await expect(installSkill({ + registry: 'http://registry.test', + namespace: 'global', + slug: 'demo', + targets: [{ agent: 'codex', rootDir, scope: 'project', source: 'explicit' }], + force: true, + home + })).rejects.toMatchObject({ + exitCode: EXIT.validation, + message: 'downloaded skill fingerprint does not match the resolved release' + }) + + expect(await readFile(join(skillDir, 'SKILL.md'), 'utf-8')).toBe('# Existing') + expect(await readFile(inventoryPath, 'utf-8')).toBe(inventoryBefore) + expect((await readdir(rootDir)).some(name => name.includes('.install-'))).toBe(false) + }) + test('rejects canonical target aliases before writing any installation', async () => { globalThis.fetch = installFetch({ 'SKILL.md': '# Demo' }) const home = await mkdtemp(join(tmpdir(), 'skillhub-install-home-')) @@ -128,6 +201,70 @@ describe('installSkill', () => { } }) + test('migrates a canonical inventory target to its selected path alias without duplication', async () => { + globalThis.fetch = installFetch({ 'SKILL.md': '# Reinstalled' }) + const home = await mkdtemp(join(tmpdir(), 'skillhub-install-home-')) + const parent = await mkdtemp(join(tmpdir(), 'skillhub-install-alias-')) + const realRoot = join(parent, 'real') + const aliasRoot = join(parent, 'alias') + const realSkillDir = join(realRoot, 'demo') + const aliasSkillDir = join(aliasRoot, 'demo') + await mkdir(realSkillDir, { recursive: true }) + await writeFile(join(realSkillDir, 'SKILL.md'), '# Old') + await writeManagedMetadata(realSkillDir) + await symlink(realRoot, aliasRoot, process.platform === 'win32' ? 'junction' : 'dir') + + 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: realRoot, + installDir: realSkillDir, + installedAt: '2026-09-01T00:00:00Z' + }] + }] + })) + + await installSkill({ + registry: 'http://registry.test', + namespace: 'global', + slug: 'demo', + targets: [{ agent: 'codex', rootDir: aliasRoot, scope: 'project', source: 'explicit' }], + force: true, + home + }) + + const inventory = JSON.parse(await readFile(inventoryPath, 'utf-8')) + expect(inventory.items).toHaveLength(1) + expect(inventory.items[0].targets).toEqual([ + expect.objectContaining({ rootDir: aliasRoot, installDir: aliasSkillDir }) + ]) + + const plan = await planSkillUpgrades({ + coordinates: ['@global/demo'], + registry: 'http://registry.test', + force: false, + home, + tokenForRegistry: async () => undefined + }) + expect(plan).toMatchObject({ blocked: 0, unchanged: 1 }) + + await removeLocalSkill({ + registry: 'http://registry.test', + namespace: 'global', + slug: 'demo', + home + }) + expect(await exists(realSkillDir)).toBe(false) + expect((JSON.parse(await readFile(inventoryPath, 'utf-8'))).items).toEqual([]) + }) + test('force replaces the old skill directory instead of overlaying files', async () => { globalThis.fetch = installFetch({ 'SKILL.md': '# New' }) const home = await mkdtemp(join(tmpdir(), 'skillhub-install-home-')) @@ -135,6 +272,7 @@ describe('installSkill', () => { const skillDir = join(rootDir, 'demo') await mkdir(skillDir, { recursive: true }) await writeFile(join(skillDir, 'stale.txt'), 'old') + await writeManagedMetadata(skillDir) await installSkill({ registry: 'http://registry.test', @@ -149,12 +287,39 @@ describe('installSkill', () => { expect(await exists(join(skillDir, 'stale.txt'))).toBe(false) }) - test('force removes stale inventory records that point at the replaced install directory', async () => { + test('reports lock cleanup failure as a warning after committing the installation', async () => { + globalThis.fetch = installFetch({ 'SKILL.md': '# Committed' }) + const home = await mkdtemp(join(tmpdir(), 'skillhub-install-home-')) + const rootDir = await mkdtemp(join(tmpdir(), 'skillhub-install-root-')) + const skillDir = join(rootDir, 'demo') + + const result = await installSkill({ + registry: 'http://registry.test', + namespace: 'global', + slug: 'demo', + targets: [{ agent: 'codex', rootDir, scope: 'project', source: 'explicit' }], + force: false, + home, + acquireTargetLock: async () => async () => { throw new Error('simulated release failure') } + }) + + expect(result.warnings).toEqual(['target lock cleanup failed: simulated release failure']) + expect(await readFile(join(skillDir, 'SKILL.md'), 'utf-8')).toBe('# Committed') + const inventory = JSON.parse(await readFile(join(home, '.skillhub', 'inventory.json'), 'utf-8')) + expect(inventory.items[0]).toMatchObject({ + version: '1.0.0', + fingerprint: skillFingerprint({ 'SKILL.md': '# Committed' }) + }) + }) + + test('force rejects a different namespace at the same install directory', async () => { globalThis.fetch = installFetch({ 'SKILL.md': '# Team Demo' }) 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'), '# Global Demo') + await writeManagedMetadata(skillDir) const inventoryPath = join(home, '.skillhub', 'inventory.json') await mkdir(join(home, '.skillhub'), { recursive: true }) await writeFile(inventoryPath, JSON.stringify({ @@ -172,20 +337,241 @@ describe('installSkill', () => { }] })) - await installSkill({ + await expect(installSkill({ registry: 'http://registry.test', namespace: 'team', slug: 'demo', targets: [{ agent: 'codex', rootDir, scope: 'project', source: 'explicit' }], force: true, home - }) + })).rejects.toThrow('source conflict') const inventory = JSON.parse(await readFile(inventoryPath, 'utf-8')) expect(inventory.items).toHaveLength(1) - expect(inventory.items[0]).toMatchObject({ namespace: 'team', slug: 'demo' }) + expect(inventory.items[0]).toMatchObject({ namespace: 'global', slug: 'demo' }) expect(inventory.items[0].targets).toHaveLength(1) expect(inventory.items[0].targets[0].installDir).toBe(skillDir) + expect(await readFile(join(skillDir, 'SKILL.md'), 'utf-8')).toBe('# Global Demo') + }) + + test('force rejects metadata explicitly owned by another installer', async () => { + globalThis.fetch = installFetch({ 'SKILL.md': '# New' }) + 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'), '# Manual') + await writeManagedMetadata(skillDir) + const metadataPath = join(skillDir, '.skillhub', 'metadata.json') + const metadata = JSON.parse(await readFile(metadataPath, 'utf-8')) + metadata.source = 'manual' + await writeFile(metadataPath, JSON.stringify(metadata)) + const metadataBefore = await readFile(metadataPath, 'utf-8') + + await expect(installSkill({ + registry: 'http://registry.test', + namespace: 'global', + slug: 'demo', + targets: [{ agent: 'codex', rootDir, scope: 'project', source: 'explicit' }], + force: true, + home + })).rejects.toThrow('cannot verify SkillHub ownership') + expect(await readFile(join(skillDir, 'SKILL.md'), 'utf-8')).toBe('# Manual') + expect(await readFile(metadataPath, 'utf-8')).toBe(metadataBefore) + expect(await exists(join(home, '.skillhub', 'inventory.json'))).toBe(false) + const entries = await readdir(rootDir) + expect(entries.some(name => name.includes('skillhub-backup'))).toBe(false) + expect(entries.some(name => name.includes('skillhub-install.lock'))).toBe(false) + }) + + test('force rejects a directory without installation metadata', async () => { + globalThis.fetch = installFetch({ 'SKILL.md': '# New' }) + 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, 'local.txt'), 'keep') + + await expect(installSkill({ + registry: 'http://registry.test', namespace: 'global', slug: 'demo', + targets: [{ agent: 'codex', rootDir, scope: 'project', source: 'explicit' }], + force: true, home + })).rejects.toThrow('cannot verify SkillHub ownership') + expect(await readFile(join(skillDir, 'local.txt'), 'utf-8')).toBe('keep') + expect(await exists(join(home, '.skillhub', 'inventory.json'))).toBe(false) + }) + + test('force rejects a different slug in installation metadata', async () => { + globalThis.fetch = installFetch({ 'SKILL.md': '# New' }) + 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'), '# Other') + await writeManagedMetadata(skillDir, { + registry: 'http://registry.test', namespace: 'global', slug: 'other' + }) + + await expect(installSkill({ + registry: 'http://registry.test', namespace: 'global', slug: 'demo', + targets: [{ agent: 'codex', rootDir, scope: 'project', source: 'explicit' }], + force: true, home + })).rejects.toThrow('source conflict') + expect(await readFile(join(skillDir, 'SKILL.md'), 'utf-8')).toBe('# Other') + expect(await exists(join(home, '.skillhub', 'inventory.json'))).toBe(false) + }) + + test('revalidates ownership after download before replacing the target', async () => { + 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') + await writeManagedMetadata(skillDir) + const archive = zipSync({ 'SKILL.md': new TextEncoder().encode('# New') }) + + globalThis.fetch = (async (input: URL | RequestInfo) => { + const path = new URL(String(input)).pathname + if (path.endsWith('/resolve')) { + return Response.json({ + code: 0, + data: { + namespace: 'global', + slug: 'demo', + version: '1.0.0', + versionId: 1, + fingerprint: skillFingerprint({ 'SKILL.md': '# New' }), + downloadUrl: '/download' + } + }) + } + if (path.endsWith('/download')) { + await writeManagedMetadata(skillDir, { + registry: 'http://other-registry.test', + namespace: 'global', + slug: 'demo' + }) + await writeFile(join(skillDir, 'SKILL.md'), '# Replaced during download') + return new Response( + archive.buffer.slice(archive.byteOffset, archive.byteOffset + archive.byteLength) as ArrayBuffer, + { status: 200 } + ) + } + return Response.json({ code: 404 }, { status: 404 }) + }) as typeof fetch + + await expect(installSkill({ + registry: 'http://registry.test', + namespace: 'global', + slug: 'demo', + targets: [{ agent: 'codex', rootDir, scope: 'project', source: 'explicit' }], + force: true, + home + })).rejects.toThrow('source conflict') + + expect(await readFile(join(skillDir, 'SKILL.md'), 'utf-8')).toBe('# Replaced during download') + const metadata = JSON.parse(await readFile(join(skillDir, '.skillhub', 'metadata.json'), 'utf-8')) + expect(metadata.registry).toBe('http://other-registry.test') + expect((await readdir(rootDir)).some(name => name.includes('skillhub-backup'))).toBe(false) + expect(await exists(await skillTargetLockPath(rootDir, 'demo'))).toBe(false) + expect(await exists(join(home, '.skillhub', 'inventory.json'))).toBe(false) + }) + + test('rolls back every target when a later target changes source before commit', async () => { + const home = await mkdtemp(join(tmpdir(), 'skillhub-install-home-')) + const parent = await mkdtemp(join(tmpdir(), 'skillhub-install-targets-')) + const firstRoot = join(parent, 'a') + const secondRoot = join(parent, 'b') + const firstSkillDir = join(firstRoot, 'demo') + const secondSkillDir = join(secondRoot, 'demo') + for (const skillDir of [firstSkillDir, secondSkillDir]) { + await mkdir(skillDir, { recursive: true }) + await writeFile(join(skillDir, 'SKILL.md'), `# Old ${skillDir === firstSkillDir ? 'A' : 'B'}`) + await writeManagedMetadata(skillDir) + } + await mkdir(join(home, '.skillhub'), { recursive: true }) + await writeFile(join(home, '.skillhub', 'inventory.json'), JSON.stringify({ + items: [{ + registry: 'http://registry.test', + namespace: 'global', + slug: 'demo', + version: '0.1.0', + targets: [ + { agent: 'codex', rootDir: firstRoot, installDir: firstSkillDir, installedAt: '2026-09-01T00:00:00Z' }, + { agent: 'claude-code', rootDir: secondRoot, installDir: secondSkillDir, installedAt: '2026-09-01T00:00:00Z' } + ] + }] + })) + const archive = zipSync({ 'SKILL.md': new TextEncoder().encode('# New') }) + globalThis.fetch = (async (input: URL | RequestInfo) => { + const path = new URL(String(input)).pathname + if (path.endsWith('/resolve')) { + return Response.json({ code: 0, data: { + namespace: 'global', slug: 'demo', version: '1.0.0', versionId: 1, + fingerprint: skillFingerprint({ 'SKILL.md': '# New' }), downloadUrl: '/download' + } }) + } + if (path.endsWith('/download')) { + await writeManagedMetadata(secondSkillDir, { + registry: 'http://other-registry.test', namespace: 'global', slug: 'demo' + }) + return new Response( + archive.buffer.slice(archive.byteOffset, archive.byteOffset + archive.byteLength) as ArrayBuffer, + { status: 200 } + ) + } + return Response.json({ code: 404 }, { status: 404 }) + }) as typeof fetch + + await expect(installSkill({ + registry: 'http://registry.test', + namespace: 'global', + slug: 'demo', + targets: [ + { agent: 'codex', rootDir: firstRoot, scope: 'project', source: 'explicit' }, + { agent: 'claude-code', rootDir: secondRoot, scope: 'project', source: 'explicit' } + ], + force: true, + home + })).rejects.toThrow('source conflict') + + expect(await readFile(join(firstSkillDir, 'SKILL.md'), 'utf-8')).toBe('# Old A') + expect(await readFile(join(secondSkillDir, 'SKILL.md'), 'utf-8')).toBe('# Old B') + const inventory = JSON.parse(await readFile(join(home, '.skillhub', 'inventory.json'), 'utf-8')) + expect(inventory.items[0]).toMatchObject({ version: '0.1.0' }) + expect(inventory.items[0].targets).toHaveLength(2) + for (const rootDir of [firstRoot, secondRoot]) { + const entries = await readdir(rootDir) + expect(entries.some(name => name.includes('skillhub-backup'))).toBe(false) + expect(await exists(await skillTargetLockPath(rootDir, 'demo'))).toBe(false) + } + }) + + test('rejects an active target lock and recovers a dead-process lock', async () => { + globalThis.fetch = installFetch({ 'SKILL.md': '# New' }) + const home = await mkdtemp(join(tmpdir(), 'skillhub-install-home-')) + const rootDir = await mkdtemp(join(tmpdir(), 'skillhub-install-root-')) + const lockPath = await skillTargetLockPath(rootDir, 'demo') + await mkdir(lockPath) + + await expect(installSkill({ + registry: 'http://registry.test', namespace: 'global', slug: 'demo', + targets: [{ agent: 'codex', rootDir, scope: 'project', source: 'explicit' }], + force: false, home + })).rejects.toThrow('install target is busy') + expect(await exists(join(rootDir, 'demo'))).toBe(false) + + await rm(lockPath, { recursive: true }) + await mkdir(lockPath) + const staleTime = new Date(Date.now() - 60_000) + await utimes(lockPath, staleTime, staleTime) + await installSkill({ + registry: 'http://registry.test', namespace: 'global', slug: 'demo', + targets: [{ agent: 'codex', rootDir, scope: 'project', source: 'explicit' }], + force: false, home + }) + expect(await readFile(join(rootDir, 'demo', 'SKILL.md'), 'utf-8')).toBe('# New') + expect(await exists(lockPath)).toBe(false) }) test('force keeps old installation and inventory when replacement extraction fails', async () => { @@ -195,6 +581,7 @@ describe('installSkill', () => { const skillDir = join(rootDir, 'demo') await mkdir(skillDir, { recursive: true }) await writeFile(join(skillDir, 'SKILL.md'), '# Old') + await writeManagedMetadata(skillDir) const inventoryPath = join(home, '.skillhub', 'inventory.json') await mkdir(join(home, '.skillhub'), { recursive: true }) await writeFile(inventoryPath, JSON.stringify({ @@ -234,6 +621,7 @@ describe('installSkill', () => { const skillDir = join(rootDir, 'demo') await mkdir(skillDir, { recursive: true }) await writeFile(join(skillDir, 'SKILL.md'), '# Old') + await writeManagedMetadata(skillDir) const invalidHome = join(rootDir, 'home-is-a-file') await writeFile(invalidHome, 'not a directory') diff --git a/cli/test/unit/services/remove-service.test.ts b/cli/test/unit/services/remove-service.test.ts index 2e686f06..e5e10571 100644 --- a/cli/test/unit/services/remove-service.test.ts +++ b/cli/test/unit/services/remove-service.test.ts @@ -1,8 +1,9 @@ -import { access, mkdir, mkdtemp } from 'node:fs/promises' +import { access, mkdir, mkdtemp, symlink } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { describe, expect, test } from 'bun:test' import { removeLocalSkill } from '../../../src/services/remove-service' +import { acquireSkillTargetLock, skillTargetLockPath } from '../../../src/services/skill-target-lock' import { InventoryStore } from '../../../src/stores/inventory-store' async function exists(path: string): Promise { @@ -92,6 +93,98 @@ describe('removeLocalSkill', () => { expect((await store.read()).items.map(item => item.namespace)).toEqual(['global']) }) + test('does not remove a target while install or upgrade holds its lifecycle lock', async () => { + const home = await mkdtemp(join(tmpdir(), 'skillhub-remove-lock-home-')) + const rootDir = await mkdtemp(join(tmpdir(), 'skillhub-remove-lock-root-')) + const skillDir = join(rootDir, 'demo') + await mkdir(skillDir, { recursive: true }) + const store = new InventoryStore(home) + await store.write({ + items: [{ + registry: 'https://skill.xfyun.cn', + namespace: 'global', + slug: 'demo', + version: '1.0.0', + targets: [{ agent: 'codex', rootDir, installDir: skillDir, installedAt: '2026-09-01T00:00:00Z' }] + }] + }) + + const release = await acquireSkillTargetLock(rootDir, 'demo') + try { + await expect(removeLocalSkill({ registry: 'https://skill.xfyun.cn', slug: 'demo', home })) + .rejects.toThrow('install target is busy') + expect(await exists(skillDir)).toBe(true) + expect((await store.read()).items[0]?.targets).toHaveLength(1) + } finally { + await release() + } + + const removed = await removeLocalSkill({ registry: 'https://skill.xfyun.cn', slug: 'demo', home }) + expect(removed.removed).toHaveLength(1) + expect(await exists(skillDir)).toBe(false) + expect((await store.read()).items).toEqual([]) + expect(await exists(await skillTargetLockPath(rootDir, 'demo'))).toBe(false) + }) + + test('legacy symlink roots use the same lifecycle lock as their real target', async () => { + const home = await mkdtemp(join(tmpdir(), 'skillhub-remove-alias-home-')) + const parent = await mkdtemp(join(tmpdir(), 'skillhub-remove-alias-parent-')) + const realRoot = join(parent, 'real-root') + const aliasRoot = join(parent, 'legacy-alias') + const realSkillDir = join(realRoot, 'demo') + const aliasSkillDir = join(aliasRoot, 'demo') + await mkdir(realSkillDir, { recursive: true }) + await symlink(realRoot, aliasRoot, 'dir') + + const store = new InventoryStore(home) + await store.write({ + items: [{ + registry: 'https://skill.xfyun.cn', + namespace: 'global', + slug: 'demo', + version: '1.0.0', + targets: [{ agent: 'codex', rootDir: aliasRoot, installDir: aliasSkillDir, installedAt: '2026-09-01T00:00:00Z' }] + }] + }) + + const lockPath = await skillTargetLockPath(realRoot, 'demo') + expect(await skillTargetLockPath(aliasRoot, 'demo')).toBe(lockPath) + const release = await acquireSkillTargetLock(realRoot, 'demo') + try { + await expect(removeLocalSkill({ registry: 'https://skill.xfyun.cn', slug: 'demo', home })) + .rejects.toThrow('install target is busy') + expect(await exists(realSkillDir)).toBe(true) + expect((await store.read()).items[0]?.targets).toHaveLength(1) + } finally { + await release() + } + expect(await exists(lockPath)).toBe(false) + }) + + test('removes a stale inventory target when the recorded root directory is missing', async () => { + const home = await mkdtemp(join(tmpdir(), 'skillhub-remove-stale-home-')) + const parent = await mkdtemp(join(tmpdir(), 'skillhub-remove-stale-parent-')) + const rootDir = join(parent, 'missing-root') + const skillDir = join(rootDir, 'demo') + const store = new InventoryStore(home) + await store.write({ + items: [{ + registry: 'https://skill.xfyun.cn', + namespace: 'global', + slug: 'demo', + version: '1.0.0', + targets: [{ agent: 'codex', rootDir, installDir: skillDir, installedAt: '2026-09-01T00:00:00Z' }] + }] + }) + + const result = await removeLocalSkill({ registry: 'https://skill.xfyun.cn', slug: 'demo', home }) + + expect(result.removed).toEqual([{ namespace: 'global', agent: 'codex', dir: skillDir, existed: false }]) + expect(await exists(rootDir)).toBe(false) + expect((await store.read()).items).toEqual([]) + expect(await exists(await skillTargetLockPath(rootDir, 'demo'))).toBe(false) + }) + test('throws on path traversal in installDir', async () => { const home = await mkdtemp(join(tmpdir(), 'skillhub-remove-traversal-')) diff --git a/cli/test/unit/services/skill-target-lock.test.ts b/cli/test/unit/services/skill-target-lock.test.ts new file mode 100644 index 00000000..ddee6418 --- /dev/null +++ b/cli/test/unit/services/skill-target-lock.test.ts @@ -0,0 +1,134 @@ +import { access, chmod, lstat, mkdir, mkdtemp, symlink, unlink, utimes, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { describe, expect, test } from 'bun:test' +import { + acquireSkillTargetLock, + assertPrivateLockDir, + ensurePrivateLockDir, + skillTargetLockPath +} from '../../../src/services/skill-target-lock' + +async function exists(path: string): Promise { + try { + await access(path) + return true + } catch { + return false + } +} + +async function waitForFile(path: string): Promise { + for (let attempt = 0; attempt < 500; attempt++) { + if (await exists(path)) return + await Bun.sleep(10) + } + throw new Error(`timed out waiting for ${path}`) +} + +describe('skill target lifecycle lock', () => { + test('creates or repairs a private lock root and rejects unsafe roots', async () => { + const parent = await mkdtemp(join(tmpdir(), 'skillhub-lock-root-')) + const privateRoot = join(parent, 'private') + await ensurePrivateLockDir(privateRoot) + if (process.platform !== 'win32') { + expect((await lstat(privateRoot)).mode & 0o077).toBe(0) + await chmod(privateRoot, 0o755) + await ensurePrivateLockDir(privateRoot) + expect((await lstat(privateRoot)).mode & 0o077).toBe(0) + } + + const fileRoot = join(parent, 'file') + await writeFile(fileRoot, 'keep') + await expect(ensurePrivateLockDir(fileRoot)).rejects.toThrow('unsafe SkillHub CLI lock directory') + expect(await Bun.file(fileRoot).text()).toBe('keep') + + const symlinkTarget = join(parent, 'symlink-target') + const symlinkRoot = join(parent, 'symlink') + await mkdir(symlinkTarget) + await symlink(symlinkTarget, symlinkRoot, 'dir') + await expect(ensurePrivateLockDir(symlinkRoot)).rejects.toThrow('unsafe SkillHub CLI lock directory') + expect((await lstat(symlinkRoot)).isSymbolicLink()).toBe(true) + + expect(() => assertPrivateLockDir('/foreign', { + isDirectory: () => true, + isSymbolicLink: () => false, + uid: 2000, + mode: 0o40700 + }, 1000)).toThrow('owned by another user') + }) + + test('simultaneous stale recovery admits exactly one owner across processes', async () => { + const rootDir = await mkdtemp(join(tmpdir(), 'skillhub-target-lock-root-')) + const lockPath = await skillTargetLockPath(rootDir, 'demo') + await mkdir(lockPath) + const staleTime = new Date(Date.now() - 60_000) + await utimes(lockPath, staleTime, staleTime) + const worker = fileURLToPath(new URL('../../helpers/target-lock-worker.ts', import.meta.url)) + const bunPath = (await Bun.which('bun')) ?? process.execPath + const acquiredPath = join(rootDir, 'acquired') + const releasePath = join(rootDir, 'release') + const startPath = join(rootDir, 'start') + const readyPaths = [join(rootDir, 'ready-0'), join(rootDir, 'ready-1')] + + const processes = readyPaths.map(readyPath => Bun.spawn({ + cmd: [bunPath, worker, rootDir, 'demo', readyPath, startPath, acquiredPath, releasePath], + stdout: 'pipe', + stderr: 'pipe' + })) + try { + await Promise.all(readyPaths.map(waitForFile)) + await writeFile(startPath, 'start') + await waitForFile(acquiredPath) + const loserExitCode = await Promise.race([ + ...processes.map(process => process.exited), + Bun.sleep(5_000).then(() => { throw new Error('timed out waiting for the lock loser') }) + ]) + expect(loserExitCode).toBe(4) + } finally { + try { + await writeFile(releasePath, 'release') + } finally { + const exited = await Promise.race([ + Promise.all(processes.map(process => process.exited)).then(() => true), + Bun.sleep(5_000).then(() => false) + ]) + if (!exited) { + for (const process of processes) process.kill() + await Promise.all(processes.map(process => process.exited)) + } + } + } + const results = await Promise.all(processes.map(async process => ({ + exitCode: await process.exited, + stdout: (await new Response(process.stdout).text()).trim(), + stderr: (await new Response(process.stderr).text()).trim() + }))) + + expect(results.map(result => result.exitCode).sort()).toEqual([0, 4]) + expect(results.filter(result => result.stdout === 'acquired')).toHaveLength(1) + expect(await exists(lockPath)).toBe(false) + if (process.platform !== 'win32') { + expect((await lstat(dirname(lockPath))).mode & 0o077).toBe(0) + } + }, 15_000) + + test('keeps one lock identity when a symlink target is removed', async () => { + const rootDir = await mkdtemp(join(tmpdir(), 'skillhub-target-symlink-root-')) + const linkedDir = await mkdtemp(join(tmpdir(), 'skillhub-target-symlink-value-')) + const skillDir = join(rootDir, 'demo') + await symlink(linkedDir, skillDir, process.platform === 'win32' ? 'junction' : 'dir') + + const lockPathBeforeRemoval = await skillTargetLockPath(rootDir, 'demo') + const release = await acquireSkillTargetLock(rootDir, 'demo') + try { + await unlink(skillDir) + expect(await skillTargetLockPath(rootDir, 'demo')).toBe(lockPathBeforeRemoval) + await expect(acquireSkillTargetLock(rootDir, 'demo')).rejects.toThrow('install target is busy') + } finally { + await release() + } + expect(await exists(lockPathBeforeRemoval)).toBe(false) + }) +}) diff --git a/cli/test/unit/stores/inventory-store.test.ts b/cli/test/unit/stores/inventory-store.test.ts index 9a4ca3e5..58ccaf21 100644 --- a/cli/test/unit/stores/inventory-store.test.ts +++ b/cli/test/unit/stores/inventory-store.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from 'bun:test' -import { mkdir, writeFile } from 'node:fs/promises' +import { mkdir, utimes } from 'node:fs/promises' import { mkdtemp } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join, dirname } from 'node:path' @@ -37,19 +37,16 @@ describe('InventoryStore', () => { expect(inventory.items).toHaveLength(5) }) - test('recovers from stale lock file', async () => { + test('recovers from a stale lock directory', async () => { const home = await makeTempHome() const store = new InventoryStore(home) - // Ensure the directory for the lock file exists + // proper-lockfile uses atomic mkdir and an mtime lease. await mkdir(dirname(store.path), { recursive: true }) - - // Write a stale lock: dead PID + old timestamp const lockPath = `${store.path}.lock` - await writeFile( - lockPath, - JSON.stringify({ pid: 999999, timestamp: Date.now() - 60000 }), - ) + await mkdir(lockPath) + const staleTime = new Date(Date.now() - 60_000) + await utimes(lockPath, staleTime, staleTime) // upsertTarget should recover from the stale lock and succeed await store.upsertTarget( @@ -98,4 +95,33 @@ describe('InventoryStore', () => { const inventory = await store.read() expect(inventory.items).toHaveLength(1) }) + + test('rejects a version change while an unselected target is retained', async () => { + const home = await makeTempHome() + const store = new InventoryStore(home) + const retained = makeTarget('shared-a') + await store.upsertTarget( + 'https://skill.xfyun.cn', + 'global', + 'shared', + '1.0.0', + retained, + 'fp-v1', + ) + + const replacement = makeTarget('shared-b') + await expect(store.replaceTargetsAtInstallDirs( + 'https://skill.xfyun.cn', + 'global', + 'shared', + '1.1.0', + [replacement], + 'fp-v2', + )).rejects.toThrow('partial-target install would create inconsistent versions') + + const inventory = await store.read() + expect(inventory.items).toHaveLength(1) + expect(inventory.items[0]).toMatchObject({ version: '1.0.0', fingerprint: 'fp-v1' }) + expect(inventory.items[0]?.targets).toEqual([retained]) + }) }) diff --git a/compose.release.yml b/compose.release.yml index 8428d6e1..97381d11 100644 --- a/compose.release.yml +++ b/compose.release.yml @@ -6,6 +6,8 @@ services: SKILL_SCANNER_LLM_API_KEY: ${SKILL_SCANNER_LLM_API_KEY:-} SKILL_SCANNER_LLM_BASE_URL: ${SKILL_SCANNER_LLM_BASE_URL:-} SKILL_SCANNER_LLM_MODEL: ${SKILL_SCANNER_LLM_MODEL:-} + SKILLHUB_SCANNER_MAX_CONCURRENT_SCANS: ${SKILLHUB_SCANNER_MAX_CONCURRENT_SCANS:-1} + SKILLHUB_SCANNER_HARD_TIMEOUT_SECONDS: ${SKILLHUB_SCANNER_HARD_TIMEOUT_SECONDS:-930} healthcheck: test: ["CMD", "wget", "-qO-", "http://127.0.0.1:8000/health"] interval: 10s @@ -87,6 +89,10 @@ services: SKILLHUB_SECURITY_SCANNER_ENABLED: ${SKILLHUB_SECURITY_SCANNER_ENABLED:-true} SKILLHUB_SECURITY_SCANNER_URL: http://skill-scanner:8000 SKILLHUB_SECURITY_SCANNER_MODE: upload + SKILLHUB_SECURITY_SCANNER_CONNECT_TIMEOUT: ${SKILLHUB_SECURITY_SCANNER_CONNECT_TIMEOUT:-5000} + SKILLHUB_SECURITY_SCANNER_READ_TIMEOUT: ${SKILLHUB_SECURITY_SCANNER_READ_TIMEOUT:-900000} + SKILLHUB_SECURITY_SCANNER_RETRY_MAX: ${SKILLHUB_SECURITY_SCANNER_RETRY_MAX:-3} + SKILLHUB_SCAN_STREAM_RECLAIM_MIN_IDLE: ${SKILLHUB_SCAN_STREAM_RECLAIM_MIN_IDLE:-PT16M} SKILLHUB_BUILTIN_SKILLS_ENABLED: ${SKILLHUB_BUILTIN_SKILLS_ENABLED:-true} SKILLHUB_AUTH_DIRECT_ENABLED: ${SKILLHUB_AUTH_DIRECT_ENABLED:-false} SKILLHUB_TRACING_MODE: ${SKILLHUB_TRACING_MODE:-none} diff --git a/deploy/k8s/base/backend-deployment.yaml b/deploy/k8s/base/backend-deployment.yaml index 52ddc185..816ffed3 100644 --- a/deploy/k8s/base/backend-deployment.yaml +++ b/deploy/k8s/base/backend-deployment.yaml @@ -160,6 +160,16 @@ spec: configMapKeyRef: name: skillhub-config key: skill-scanner-mode + - name: SKILLHUB_SECURITY_SCANNER_READ_TIMEOUT + valueFrom: + configMapKeyRef: + name: skillhub-config + key: skill-scanner-read-timeout + - name: SKILLHUB_SCAN_STREAM_RECLAIM_MIN_IDLE + valueFrom: + configMapKeyRef: + name: skillhub-config + key: skill-scan-reclaim-min-idle # Session - name: SESSION_COOKIE_SECURE diff --git a/deploy/k8s/base/configmap.yaml b/deploy/k8s/base/configmap.yaml index 06dbbaa5..295da98a 100644 --- a/deploy/k8s/base/configmap.yaml +++ b/deploy/k8s/base/configmap.yaml @@ -35,6 +35,10 @@ data: skill-scanner-enabled: "true" skill-scanner-url: http://skillhub-scanner:8000 skill-scanner-mode: upload + skill-scanner-read-timeout: "900000" + skill-scan-reclaim-min-idle: PT16M + skill-scanner-max-concurrent-scans: "1" + skill-scanner-hard-timeout-seconds: "930" # Bootstrap 管理员配置(非敏感) bootstrap-admin-enabled: "true" diff --git a/deploy/k8s/base/scanner-deployment.yaml b/deploy/k8s/base/scanner-deployment.yaml index 91c7f3e3..26fb3667 100644 --- a/deploy/k8s/base/scanner-deployment.yaml +++ b/deploy/k8s/base/scanner-deployment.yaml @@ -40,6 +40,16 @@ spec: name: skillhub-secret key: skill-scanner-llm-model optional: true + - name: SKILLHUB_SCANNER_MAX_CONCURRENT_SCANS + valueFrom: + configMapKeyRef: + name: skillhub-config + key: skill-scanner-max-concurrent-scans + - name: SKILLHUB_SCANNER_HARD_TIMEOUT_SECONDS + valueFrom: + configMapKeyRef: + name: skillhub-config + key: skill-scanner-hard-timeout-seconds readinessProbe: httpGet: path: /health diff --git a/docs/02-domain-model.md b/docs/02-domain-model.md index 5aaaa4a6..f076addd 100644 --- a/docs/02-domain-model.md +++ b/docs/02-domain-model.md @@ -230,10 +230,18 @@ | skill_id | bigint | | | user_id | varchar(128) | | | score | tinyint | 1-5 | +| review_text | varchar(2000) | 可选文字评价;空值表示仅评分 | +| review_status | enum | `VISIBLE` / `HIDDEN`,隐藏不影响评分聚合 | +| moderated_by | varchar(128) | 最近一次管理操作人,nullable | +| moderated_at | datetime | 最近一次管理时间,nullable | +| moderation_reason | varchar(500) | 隐藏原因,nullable | +| lock_version | bigint | 乐观锁版本;并发编辑或治理冲突返回 409 | | created_at | datetime | | | updated_at | datetime | | -唯一约束:`(skill_id, user_id)`,每人每技能一条,可修改 +唯一约束:`(skill_id, user_id)`,每人每技能一条,可修改。删除文字评价只清空 +`review_text`,保留评分和既有治理状态;管理员隐藏评价时也保留评分,避免作者通过 +清空后重新提交绕过治理,或治理动作改变聚合分数。 ### user_account diff --git a/docs/05-business-flows.md b/docs/05-business-flows.md index 3cd6a7cb..74b206a6 100644 --- a/docs/05-business-flows.md +++ b/docs/05-business-flows.md @@ -252,6 +252,10 @@ Web 端与 CLI 保持同一发布语义,只是在交互上可提供更明确 → 异步重算 skill.rating_avg 和 rating_count(SELECT AVG + Redis 分布式锁防重复重算) ``` +文字评价复用同一条 `skill_rating` 记录:用户提交 `score + review_text` 时同步更新评分并触发 +`SkillRatedEvent`;删除评价只清空文字,保留星级评分。公开列表仅返回 `VISIBLE` 评价; +`SKILL_ADMIN` / `SUPER_ADMIN` 可隐藏或恢复评价,管理动作写入审计日志且不改变评分聚合。 + ## 7 异步事件汇总 | 事件 | 触发时机 | 消费方 | diff --git a/docs/06-api-design.md b/docs/06-api-design.md index 56cb5541..a0f580ae 100644 --- a/docs/06-api-design.md +++ b/docs/06-api-design.md @@ -82,6 +82,7 @@ | GET | `/api/v1/skills/{namespace}/{slug}/tags/{tagName}/download` | 按标签下载(解析标签指向的版本后下载) | | GET | `/api/v1/skills/{namespace}/{slug}/tags/{tagName}/files` | 按标签查看文件清单 | | GET | `/api/v1/skills/{namespace}/{slug}/tags/{tagName}/file?path=...` | 按标签读取单个文件 | +| GET | `/api/v1/skills/{skillId}/reviews` | 公开评价分页列表;仅返回可见评价,管理员可见隐藏项 | | GET | `/api/v1/namespaces` | 公开命名空间列表 | | GET | `/api/v1/namespaces/{slug}` | 命名空间详情 | @@ -201,6 +202,9 @@ Public API 的可见性规则: | POST | `/api/v1/skills/{namespace}/{slug}/star` | 收藏 | | DELETE | `/api/v1/skills/{namespace}/{slug}/star` | 取消收藏 | | POST | `/api/v1/skills/{namespace}/{slug}/rating` | 评分 | +| GET | `/api/v1/skills/{skillId}/reviews/me` | 当前用户的评分与文字评价 | +| PUT | `/api/v1/skills/{skillId}/reviews/me` | 新增或更新当前用户评价(`score` 1-5,`reviewText` 最长 2000) | +| DELETE | `/api/v1/skills/{skillId}/reviews/me` | 删除文字评价并保留星级评分 | | GET | `/api/v1/me/stars` | 我的收藏列表 | | GET | `/api/v1/me/skills` | 我发布的技能列表 | @@ -312,6 +316,8 @@ Admin API 按最小权限拆分,不再统一要求 SUPER_ADMIN: | POST | `/api/v1/admin/skills/{id}/hide` | 隐藏技能(仅 `SUPER_ADMIN`) | | POST | `/api/v1/admin/skills/{id}/unhide` | 恢复技能(仅 `SUPER_ADMIN`) | | POST | `/api/v1/admin/skills/versions/{versionId}/yank` | 撤回已发布版本(`SKILL_ADMIN` / `SUPER_ADMIN`) | +| POST | `/api/v1/admin/skill-reviews/{reviewId}/hide` | 隐藏用户评价(`SKILL_ADMIN` / `SUPER_ADMIN`) | +| POST | `/api/v1/admin/skill-reviews/{reviewId}/restore` | 恢复用户评价(`SKILL_ADMIN` / `SUPER_ADMIN`) | ### 用户治理(需 USER_ADMIN / SUPER_ADMIN) diff --git a/docs/openclaw-integration-en.md b/docs/openclaw-integration-en.md index 32cb2878..3c022717 100644 --- a/docs/openclaw-integration-en.md +++ b/docs/openclaw-integration-en.md @@ -1,25 +1,31 @@ # OpenClaw Integration Guide -This document explains how to configure OpenClaw CLI to connect to a SkillHub private registry for publishing, searching, and downloading skills. +This document explains how to configure the ClawHub CLI to connect to a private SkillHub registry for search, inspection, and installation. Use the first-party SkillHub CLI for publishing. > Not only applicable to Openclaw, but also compatible with other CLI Coding Agents (Claude Code, OpenCode, Qcoder, etc.) or Agent assistants (Nanobot, CoPaw, etc.) by specifying the installation directory. ## Overview -SkillHub provides a ClawHub-compatible API layer, allowing OpenClaw CLI to seamlessly integrate with private registries. With simple configuration, you can: +SkillHub provides a ClawHub-compatible API for common read and install flows. With simple configuration, you can: - 🔍 Search for private skills within your organization - 📥 Download and install skill packages -- 📤 Publish new skills to the private registry -- ⭐ Star and rate skills +- ⭐ Star skills + +Current compatibility boundaries: + +- ClawHub CLI `0.23.3` uses `/api/v1/whoami`, which matches the SkillHub compatibility API. +- ClawHub publishing depends on upload-ticket endpoints that SkillHub does not implement, so `clawhub publish` and `clawhub sync` are not supported. +- ClawHub CLI `0.23.3` does not reliably prioritize the private Registry saved during `login`; site discovery or the default can select another target. Set `CLAWHUB_REGISTRY` in each shell session or pass `--registry` explicitly. +- Canonical slugs use `--` between namespace and skill. SkillHub rejects new namespace or skill slugs containing consecutive `--`, keeping new coordinates unambiguous. Rename invalid legacy or externally imported coordinates before using the ClawHub CLI. ## Quick Start ### 1. Configure Registry URL -Set the SkillHub registry address in your OpenClaw configuration: +Set the SkillHub registry address for the current shell session: ```bash -# Via environment variable (temporary) +# Do not depend on the registry resolution order of the login configuration export CLAWHUB_REGISTRY=https://skillhub.your-company.com ``` @@ -29,7 +35,7 @@ For **global namespace (@global) PUBLIC skills**, no login is required to downlo - Team namespace skills (regardless of visibility) - NAMESPACE_ONLY or PRIVATE skills -- Write operations like publishing, starring, etc. +- Authenticated operations such as starring ```bash # Log in with an API token @@ -107,24 +113,21 @@ npx clawhub uninstall --help npx clawhub list --help ``` -### 5. Publish Skills +### 5. Publish with the SkillHub CLI + +The ClawHub CLI `0.23.3` publishing protocol is not compatible with SkillHub. Use the first-party SkillHub CLI: ```bash -# Publish to the global namespace (requires appropriate permissions) -npx clawhub publish ./my-skill --slug my-skill --name "My Skill" --version 1.0.0 - -# Publish to a team namespace such as my-space -npx clawhub publish ./my-skill --slug my-space--my-skill --name "My Skill" --version 1.0.0 -npx clawhub sync --all # Upload all skills in current folder - -# Help -npx clawhub publish --help -npx clawhub sync --help +export SKILLHUB_REGISTRY=https://skillhub.your-company.com +export SKILLHUB_TOKEN=YOUR_API_TOKEN +npx @astron-team/skillhub@latest publish ./my-skill --namespace global +npx @astron-team/skillhub@latest publish ./my-skill --namespace my-space ``` Notes: -- `my-space--my-skill` is the canonical compatibility slug. SkillHub parses it as namespace `my-space` plus skill slug `my-skill` -- To avoid mismatches between CLI display text and the final persisted coordinate, keep the `name` in `SKILL.md` aligned with the canonical slug suffix +- Publishing requires an API Token with the `skill:publish` scope and permission in the target namespace. +- `clawhub login` and the SkillHub CLI do not share credentials; set `SKILLHUB_TOKEN` separately for the first-party CLI. +- The first-party CLI uses a separate namespace option, but it still follows the server's slug validation rules. ## API Endpoints @@ -138,9 +141,9 @@ SkillHub compatibility layer provides the following endpoints: | `/api/v1/download/{slug}` | GET | Download skill (redirect) | Optional* | | `/api/v1/download` | GET | Download skill (query params) | Optional* | | `/api/v1/skills/{slug}` | GET | Get skill details | Optional | -| `/api/v1/skills/{slug}/star` | POST | Star a skill | Required | -| `/api/v1/skills/{slug}/unstar` | DELETE | Unstar a skill | Required | -| `/api/v1/publish` | POST | Publish a skill | Required | +| `/api/v1/stars/{slug}` | POST | Star a skill | Required | +| `/api/v1/stars/{slug}` | DELETE | Unstar a skill | Required | +| `/api/v1/publish` | POST | Legacy compatibility endpoint; not used by ClawHub CLI `0.23.3` | Required | Notes: - The compatibility layer may still expose the term "latest" externally, but it must strictly mean "latest published version" @@ -186,6 +189,8 @@ SkillHub internally uses `@{namespace}/{skill}` format, but the compatibility la OpenClaw CLI uses canonical slug format, and SkillHub handles the conversion automatically. +The canonical format has no escaping rule, so SkillHub rejects new namespace or skill slugs containing consecutive `--`. If legacy or externally imported data bypassed that validation, rename the coordinate first; the first-party CLI does not bypass server-side slug validation. + ## Configuration Examples ### ClawHub CLI Environment Variables @@ -248,11 +253,15 @@ curl https://skillhub.your-company.com/api/v1/whoami \ npx clawhub search "" ``` -### Q: Permission denied when publishing? +### Q: Why does publishing with the ClawHub CLI fail? -- Publishing to global namespace (`@global`) requires `SUPER_ADMIN` permission -- Publishing to team namespace requires OWNER or ADMIN role in that namespace -- Contact your administrator for appropriate permissions +ClawHub CLI `0.23.3` uses an upload-ticket protocol outside SkillHub's compatibility scope. This failure does not mean that the API Token was revoked; use the first-party SkillHub CLI instead. + +If the first-party CLI reports insufficient permission: + +- The publisher must be a member of the target namespace; `SUPER_ADMIN` is exempt +- A regular member may submit a publication; visibility and review rules decide whether it is published immediately or enters review +- Contact a namespace administrator to join the target namespace ### Q: Which OpenClaw versions are supported? diff --git a/docs/openclaw-integration.md b/docs/openclaw-integration.md index f85f588e..26a9b99f 100644 --- a/docs/openclaw-integration.md +++ b/docs/openclaw-integration.md @@ -1,25 +1,31 @@ # OpenClaw 集成指南 -本文档说明如何配置 OpenClaw CLI 连接到 SkillHub 私有注册中心,实现技能的发布、搜索和下载。 +本文档说明如何配置 ClawHub CLI 连接到 SkillHub 私有注册中心,实现技能的搜索、查看和安装。发布技能请使用第一方 SkillHub CLI。 > 不仅适用于 Openclaw,通过指定安装目录,可适用于其他的 CLI Coding Agent (Claude Code、OpenCode、Qcoder等) 或者 Agent 助手(Nanobot、CoPaw等)。 ## 概述 -SkillHub 提供了与 ClawHub 兼容的 API 层,使得 OpenClaw CLI 可以无缝对接私有注册中心。通过简单的配置,您可以: +SkillHub 提供 ClawHub 兼容 API,覆盖常用的只读发现和安装流程。通过简单配置,您可以: - 🔍 搜索组织内的私有技能 - 📥 下载和安装技能包 -- 📤 发布新技能到私有注册中心 -- ⭐ 收藏和评分技能 +- ⭐ 收藏技能 + +当前兼容边界: + +- 已验证的 ClawHub CLI `0.23.3` 使用 `/api/v1/whoami`,与 SkillHub 兼容层一致。 +- ClawHub CLI 的发布协议依赖 SkillHub 未实现的上传票据接口,因此 `clawhub publish` 和 `clawhub sync` 不属于支持范围。 +- ClawHub CLI `0.23.3` 不会可靠地优先使用登录时保存的私有 Registry;站点发现或默认地址可能覆盖预期目标。每个终端会话都应设置 `CLAWHUB_REGISTRY`,或在命令中显式传入 `--registry`。 +- canonical slug 使用 `--` 分隔 namespace 与 skill。SkillHub 会拒绝新建包含连续 `--` 的 namespace 或 skill slug,以保证新坐标可无歧义解析;历史或外部导入的异常坐标应先重命名,再使用 ClawHub CLI。 ## 快速开始 ### 1. 配置 Registry 地址 -在 OpenClaw 配置文件中设置 SkillHub 注册中心地址: +为当前终端会话设置 SkillHub 注册中心地址: ```bash -# 通过环境变量配置(临时) +# 不依赖 login 配置的 Registry 解析顺序 export CLAWHUB_REGISTRY=https://skillhub.your-company.com ``` @@ -29,7 +35,7 @@ export CLAWHUB_REGISTRY=https://skillhub.your-company.com - 团队命名空间的技能(无论可见性) - NAMESPACE_ONLY 或 PRIVATE 技能 -- 发布、收藏等写操作 +- 收藏等需要登录的操作 ```bash # 使用 API Token 登录 @@ -107,24 +113,21 @@ npx clawhub uninstall --help npx clawhub list --help ``` -### 5. 发布技能 +### 5. 使用 SkillHub CLI 发布技能 + +ClawHub CLI `0.23.3` 的发布协议与 SkillHub 不兼容。请使用第一方 SkillHub CLI: ```bash -# 发布到 global 空间(需要相应权限) -npx clawhub publish ./my-skill --slug my-skill --name "My Skill" --version 1.0.0 - -# 发布到如 my-space 这样的团队空间 -npx clawhub publish ./my-skill --slug my-space--my-skill --name "My Skill" --version 1.0.0 -npx clawhub sync --all # 上传当前文件夹中所有的 skill - -# 使用帮助 -npx clawhub publish --help -npx clawhub sync --help +export SKILLHUB_REGISTRY=https://skillhub.your-company.com +export SKILLHUB_TOKEN=YOUR_API_TOKEN +npx @astron-team/skillhub@latest publish ./my-skill --namespace global +npx @astron-team/skillhub@latest publish ./my-skill --namespace my-space ``` 说明: -- `my-space--my-skill` 是兼容层 canonical slug,SkillHub 会将其解析为 namespace `my-space` 和 skill slug `my-skill` -- 为避免 CLI 展示与服务端最终坐标不一致,建议让 `SKILL.md` 中的 `name` 与 canonical slug 后半段保持一致 +- 发布需要具有 `skill:publish` scope 的 API Token,以及目标 namespace 对应权限。 +- `clawhub login` 与 SkillHub CLI 不共享凭据;请为第一方 CLI 单独设置 `SKILLHUB_TOKEN`。 +- 第一方 CLI 使用独立 namespace 参数,但仍遵循服务端 slug 校验规则。 ## API 端点说明 @@ -138,9 +141,9 @@ SkillHub 兼容层提供以下端点: | `/api/v1/download/{slug}` | GET | 下载技能(重定向) | 可选* | | `/api/v1/download` | GET | 下载技能(查询参数) | 可选* | | `/api/v1/skills/{slug}` | GET | 获取技能详情 | 可选 | -| `/api/v1/skills/{slug}/star` | POST | 收藏技能 | 必需 | -| `/api/v1/skills/{slug}/unstar` | DELETE | 取消收藏 | 必需 | -| `/api/v1/publish` | POST | 发布技能 | 必需 | +| `/api/v1/stars/{slug}` | POST | 收藏技能 | 必需 | +| `/api/v1/stars/{slug}` | DELETE | 取消收藏 | 必需 | +| `/api/v1/publish` | POST | 旧版兼容发布端点;ClawHub CLI `0.23.3` 不使用 | 必需 | 说明: - 兼容层对外继续使用 “latest” 语义,但这里严格指向“最新已发布版本” @@ -186,6 +189,8 @@ SkillHub 内部使用 `@{namespace}/{skill}` 格式,但兼容层会自动转 OpenClaw CLI 使用 canonical slug 格式,SkillHub 会自动处理转换。 +canonical 格式没有转义规则,因此 SkillHub 会拒绝新建包含连续 `--` 的 namespace 或 skill slug。若历史或外部导入数据绕过了该校验,应先重命名坐标;第一方 SkillHub CLI 虽使用独立 `--namespace` 参数,也不能绕过服务端 slug 校验。 + ## 配置示例 ### ClawHub CLI 环境变量配置 @@ -248,11 +253,15 @@ curl https://skillhub.your-company.com/api/v1/whoami \ npx clawhub search "" ``` -### Q: 发布技能时提示权限不足? +### Q: 使用 ClawHub CLI 发布为什么失败? -- 发布到全局命名空间(`@global`)需要 `SUPER_ADMIN` 权限 -- 发布到团队命名空间需要是该命名空间的 OWNER 或 ADMIN -- 联系管理员分配相应权限 +ClawHub CLI `0.23.3` 使用的上传票据协议不在 SkillHub 兼容范围内。该错误不代表 API Token 已撤销;请改用第一方 SkillHub CLI。 + +如果第一方 CLI 提示权限不足: + +- 发布者必须是目标命名空间成员;`SUPER_ADMIN` 例外 +- 普通成员可提交发布,是否直接发布或进入审核由可见性和审核规则决定 +- 联系命名空间管理员加入目标空间 ### Q: 支持哪些 OpenClaw 版本? diff --git a/docs/security-scanning.md b/docs/security-scanning.md index bd8d80fc..e5e09d5d 100644 --- a/docs/security-scanning.md +++ b/docs/security-scanning.md @@ -43,11 +43,13 @@ skillhub: scan-path: /scan-upload mode: local connect-timeout-ms: 5000 - read-timeout-ms: 300000 + read-timeout-ms: 900000 retry-max-attempts: 3 stream: key: skillhub:scan:requests group: skillhub-scanners + reclaim-min-idle: PT16M + max-unavailable-age: PT1H ``` Important environment variables: @@ -55,16 +57,24 @@ Important environment variables: - `SKILLHUB_SECURITY_SCANNER_ENABLED` - `SKILLHUB_SECURITY_SCANNER_URL` - `SKILLHUB_SECURITY_SCANNER_MODE` +- `SKILLHUB_SECURITY_SCANNER_READ_TIMEOUT` - `SKILLHUB_SCAN_STREAM_KEY` - `SKILLHUB_SCAN_STREAM_GROUP` +- `SKILLHUB_SCAN_STREAM_RECLAIM_MIN_IDLE` +- `SKILLHUB_SECURITY_STREAM_MAX_UNAVAILABLE_AGE` Scanner-side optional environment variables: - `SKILL_SCANNER_LLM_API_KEY` - `SKILL_SCANNER_LLM_BASE_URL` - `SKILL_SCANNER_LLM_MODEL` +- `SKILLHUB_SCANNER_MAX_CONCURRENT_SCANS` (default `1`) +- `SKILLHUB_SCANNER_HARD_TIMEOUT_SECONDS` (default `930`) If the LLM variables are absent, the scanner should still run with non-LLM analyzers. +The default timeout ordering is server read timeout (900 seconds), scanner hard timeout +(930 seconds), then pending-message reclaim (960 seconds). A hard timeout exits the scanner process +with status `124`; Compose or Kubernetes restarts it and the Redis pending task is retried. ## Kubernetes Notes @@ -126,7 +136,11 @@ Response fields include: ## Failure Semantics - scan task retries are handled by `AbstractStreamConsumer` -- final failure marks the version as `SCAN_FAILED` +- scanner connection failures, HTTP 429, and HTTP 5xx remain pending for automatic recovery +- unavailable tasks older than `max-unavailable-age` are marked `SCAN_FAILED`, acknowledged, and removed from the Redis Stream +- the timeout is evaluated during pending reclaim; terminal handling can occur roughly one `reclaim-min-idle` plus one `reclaim-interval` after the configured age +- terminal failures retain a failure reason in the security audit response for operators and authorized users +- other final failures mark the version as `SCAN_FAILED` after retry exhaustion - even after scan failure, a review task is still created so the package does not get stuck forever This keeps the existing human review path intact while making scanner failures visible. diff --git a/docs/skillhub/en/faq.md b/docs/skillhub/en/faq.md index 7cc4500f..83ba79f2 100644 --- a/docs/skillhub/en/faq.md +++ b/docs/skillhub/en/faq.md @@ -65,8 +65,10 @@ npx clawhub search email # Install a skill package npx clawhub install my-skill -# Publish a skill package -npx clawhub publish ./my-skill +# Publish a skill package (the ClawHub CLI publishing protocol is not compatible) +export SKILLHUB_REGISTRY=http://your-skillhub-host:8080 +export SKILLHUB_TOKEN=YOUR_API_TOKEN +npx @astron-team/skillhub@latest publish ./my-skill ``` ## Q: How do I configure HTTPS? diff --git a/docs/skillhub/en/guide/cli.md b/docs/skillhub/en/guide/cli.md index 4712cbca..aba8a429 100644 --- a/docs/skillhub/en/guide/cli.md +++ b/docs/skillhub/en/guide/cli.md @@ -157,7 +157,7 @@ skillhub install pdf-parser --agent codex --agent claude-code # Install to custom directory skillhub install pdf-parser --dir ~/.claude/skills -# Force overwrite existing installation +# Reinstall a SkillHub-managed installation from the same source skillhub install pdf-parser --force ``` @@ -214,15 +214,48 @@ For a custom path or an unsupported Agent directory, use `--dir` to specify the ```json { + "schemaVersion": 1, "registry": "https://skill.xfyun.cn", "namespace": "global", "slug": "pdf-parser", "version": "1.0.0", + "versionId": 123, + "fingerprint": "sha256:...", + "files": { "SKILL.md": "sha256..." }, "agent": "codex", "installedAt": "2026-04-28T06:00:00.000Z" } ``` +The CLI creates this file after extracting the registry package. It is not included in the downloaded +ZIP, and `.skillhub/` is excluded when an installed directory is published again. + +## Upgrade Installed Skills + +```bash +# Preview without changing files +skillhub upgrade @global/skillhub-registry --check + +# Upgrade one or a bounded list of installed Skills +skillhub upgrade @global/skillhub-registry +skillhub upgrade @team/code-review @team/java-guide + +# Deterministic machine-readable plan +skillhub upgrade @team/code-review --check --json +``` + +`upgrade` never installs a missing Skill and has no implicit upgrade-all mode. Local changes require +`--force`; it only replaces a managed installation whose full `registry + namespace + slug` source +matches. + +All targets in one inventory entry are upgraded together. A filter that selects only part of that +entry is rejected because the current inventory format stores one shared version for all targets. +The command also keeps the local files when the registry resolves to an older version. +If a multi-Skill run fails after an earlier upgrade commits, execution stops and reports each item +as `upgraded`, `failed`, or `not-attempted`; a committed upgrade is never rolled back implicitly. +New installations store absolute target paths. Reinstall an older entry that still contains relative +target paths before upgrading it; the CLI cannot safely infer the original working directory. + ## Local Management ### List Installed Skills @@ -504,11 +537,26 @@ Options: - `--version ` — Version (default: latest) - `--agent ` — Agent profile (repeatable) - `--dir ` — Custom installation directory (mutually exclusive with `--scope` and `--agent`) -- `--force` — Overwrite existing installation +- `--force` — Replace an existing SkillHub-managed installation from the same source - `--registry ` — Registry URL - `--token ` — API token - `--json` — JSON output +### upgrade + +```bash +skillhub upgrade [options] +``` + +Options: +- `--namespace ` — Filter a bare slug by namespace +- `--agent ` — Filter installed targets by Agent (repeatable) +- `--dir ` — Filter installed targets by directory +- `--registry ` — Filter by installation source registry +- `--check` — Print the exact plan without modifying files +- `--force` — Replace local changes only for the same full source identity +- `--json` — JSON output + ### list ```bash @@ -604,7 +652,7 @@ skillhub search test --registry https://skillhub.example.com ### Installation Directory Conflict ```bash -# Use --force to overwrite +# Reinstall only when the existing directory has matching SkillHub source metadata skillhub install pdf-parser --force # Or remove first then install @@ -612,6 +660,9 @@ skillhub remove pdf-parser skillhub install pdf-parser ``` +`--force` never overwrites an unmanaged directory or a Skill installed from another registry, +namespace, or slug. Move or explicitly remove that directory first. + ### Corrupted Inventory ```bash diff --git a/docs/skillhub/en/guide/skill-publish.md b/docs/skillhub/en/guide/skill-publish.md index c217485b..3224f4d7 100644 --- a/docs/skillhub/en/guide/skill-publish.md +++ b/docs/skillhub/en/guide/skill-publish.md @@ -57,16 +57,19 @@ Ensure skill package conforms to SkillHub specification: 2. **Publish via CLI (Recommended)** ```bash -# Configure registry -export CLAWHUB_REGISTRY=http://localhost:8080 +# Configure the SkillHub registry +export SKILLHUB_REGISTRY=http://localhost:8080 +export SKILLHUB_TOKEN=YOUR_API_TOKEN # Publish to default namespace -npx clawhub publish ./my-skill +npx @astron-team/skillhub@latest publish ./my-skill # Publish to specific namespace -npx clawhub publish ./my-skill --namespace my-team +npx @astron-team/skillhub@latest publish ./my-skill --namespace my-team ``` +> The ClawHub CLI publish and sync protocols are not compatible with SkillHub. Use the SkillHub CLI above for publishing. + 3. **Publish via Web UI** Visit `http://localhost:3000/dashboard/publish`, select namespace, upload zip file, choose visibility, and click "Publish". diff --git a/docs/skillhub/en/guide/social.md b/docs/skillhub/en/guide/social.md index a8c4128f..be6f1049 100644 --- a/docs/skillhub/en/guide/social.md +++ b/docs/skillhub/en/guide/social.md @@ -60,6 +60,13 @@ View skill packages with the most stars and highest ratings to discover best pra 3. The rating takes effect immediately and impacts the skill package's average rating 4. You can update your rating at any time +**Writing a Review**: + +1. Select "Write a review" on a published skill's detail page +2. Choose 1-5 stars and enter up to 2,000 characters +3. You can edit or clear the text; clearing it keeps the star rating and remains available if the skill is later unpublished +4. A review hidden by an administrator stays hidden after author edits and becomes public only after an administrator restores it + **Viewing Notifications**: 1. Click the notification icon in the top navigation bar @@ -67,6 +74,11 @@ View skill packages with the most stars and highest ratings to discover best pra 3. Click a notification to navigate to the relevant page 4. Mark as read or mark all as read +The notification list and unread count refresh through ordinary HTTP requests every 10 seconds and +immediately when the window regains focus. The legacy `GET /api/v1/notifications/sse` endpoint has +been removed. Custom clients should poll `GET /api/v1/notifications` and +`GET /api/v1/notifications/unread-count` instead. + **Viewing My Stars**: 1. Navigate to `/dashboard/stars` @@ -125,6 +137,24 @@ GET /api/v1/me/stars?page=0&size=20 GET /api/v1/skills/{skillId}/rating ``` +**Review APIs**: + +```bash +# Public review list +GET /api/v1/skills/{skillId}/reviews?page=0&size=20 + +# Read, create, or update the current user's review +GET /api/v1/skills/{skillId}/reviews/me +PUT /api/v1/skills/{skillId}/reviews/me + +# Clear review text while retaining the star rating +DELETE /api/v1/skills/{skillId}/reviews/me + +# SKILL_ADMIN or SUPER_ADMIN moderation +POST /api/v1/admin/skill-reviews/{reviewId}/hide +POST /api/v1/admin/skill-reviews/{reviewId}/restore +``` + **Response Example**: ```json { @@ -137,6 +167,8 @@ GET /api/v1/skills/{skillId}/rating > **Rating Rules**: Each user can rate each skill package only once. Ratings can be updated but not deleted. +> **Review Rules**: Only published skills can be reviewed, and the public list contains visible reviews only. Refresh and retry after a concurrent-update conflict. + - **Star Count**: A skill package's star count is displayed in search results and on the detail page - **Average Rating**: A skill package's average rating affects search ranking - **Notification Settings**: Users can disable certain notification types in their settings diff --git a/docs/skillhub/en/quickstart.md b/docs/skillhub/en/quickstart.md index f2b183b9..b6a4962d 100644 --- a/docs/skillhub/en/quickstart.md +++ b/docs/skillhub/en/quickstart.md @@ -128,22 +128,25 @@ In the browser, you can add the `X-Mock-User-Id` header via a browser extension ## Install the CLI Tool -SkillHub is compatible with the OpenClaw CLI. You can use the `npx clawhub` command to manage skill packages: +Use the first-party SkillHub CLI for skill package management: ```bash -# Configure the SkillHub registry URL -export CLAWHUB_REGISTRY=http://localhost:8080 +# Install the CLI and configure the SkillHub registry URL +npm install -g @astron-team/skillhub +export SKILLHUB_REGISTRY=http://localhost:8080 # Search for skill packages -npx clawhub search email +skillhub search email # Install a skill package -npx clawhub install my-skill +skillhub install my-skill # Publish a skill package -npx clawhub publish ./my-skill +skillhub publish ./my-skill --namespace my-team ``` +Existing ClawHub workflows can still use the compatibility layer for search and install. Prefer the SkillHub CLI for new workflows and publishing. + ## Publish Your First Skill Package ### Publish via CLI (Recommended) @@ -164,13 +167,10 @@ my-skill/ ```bash # Configure the registry -export CLAWHUB_REGISTRY=http://localhost:8080 - -# Publish to the default namespace -npx clawhub publish ./my-skill +export SKILLHUB_REGISTRY=http://localhost:8080 # Publish to a specific namespace -npx clawhub publish ./my-skill --namespace my-team +skillhub publish ./my-skill --namespace my-team ``` 3. **Wait for security scanning** @@ -201,13 +201,13 @@ Administrators will receive a notification and the skill package will be officia ```bash # Search for skill packages -npx clawhub search pdf +skillhub search pdf # Install a skill package -npx clawhub install pdf-parser +skillhub install pdf-parser # Install a skill package from a specific namespace -npx clawhub install my-team--pdf-parser +skillhub install pdf-parser --namespace my-team ``` ### Using the Web UI diff --git a/docs/skillhub/faq.md b/docs/skillhub/faq.md index b16ecd81..10d255b2 100644 --- a/docs/skillhub/faq.md +++ b/docs/skillhub/faq.md @@ -65,8 +65,10 @@ npx clawhub search email # 安装技能包 npx clawhub install my-skill -# 发布技能包 -npx clawhub publish ./my-skill +# 发布技能包(ClawHub CLI 的发布协议不兼容 SkillHub) +export SKILLHUB_REGISTRY=http://your-skillhub-host:8080 +export SKILLHUB_TOKEN=YOUR_API_TOKEN +npx @astron-team/skillhub@latest publish ./my-skill ``` ## Q: 如何配置 HTTPS? diff --git a/docs/skillhub/guide/skill-publish.md b/docs/skillhub/guide/skill-publish.md index 19233eaf..72249d60 100644 --- a/docs/skillhub/guide/skill-publish.md +++ b/docs/skillhub/guide/skill-publish.md @@ -57,16 +57,19 @@ SkillHub 提供了类似 npm 的发布体验,但增加了企业级的权限控 2. **使用 CLI 发布(推荐)** ```bash -# 配置注册中心 -export CLAWHUB_REGISTRY=http://localhost:8080 +# 配置 SkillHub 注册中心 +export SKILLHUB_REGISTRY=http://localhost:8080 +export SKILLHUB_TOKEN=YOUR_API_TOKEN # 发布到默认命名空间 -npx clawhub publish ./my-skill +npx @astron-team/skillhub@latest publish ./my-skill # 发布到指定命名空间 -npx clawhub publish ./my-skill --namespace my-team +npx @astron-team/skillhub@latest publish ./my-skill --namespace my-team ``` +> ClawHub CLI 的发布与同步协议与 SkillHub 不兼容。发布请使用上面的 SkillHub CLI。 + 3. **使用 Web UI 发布** 访问 `http://localhost:3000/dashboard/publish`,选择命名空间、上传 zip 文件、选择可见性后点击「发布」。 diff --git a/docs/skillhub/guide/social.md b/docs/skillhub/guide/social.md index 6fc733ba..9b74c84f 100644 --- a/docs/skillhub/guide/social.md +++ b/docs/skillhub/guide/social.md @@ -60,6 +60,13 @@ SkillHub 提供了丰富的社交功能,让团队成员可以互动、分享 3. 评分会立即生效,影响技能包的平均评分 4. 可以随时修改评分 +**撰写评价**: + +1. 在已发布技能的详情页点击「写评价」 +2. 选择 1-5 星并填写最多 2000 字的评价 +3. 可以修改或清空评价;清空评价不会删除星级评分,即使技能之后取消发布也仍可清空自己的文字 +4. 被管理员隐藏的评价在重新编辑后仍保持隐藏,只有技能管理员或超级管理员可以恢复公开 + **查看通知**: 1. 点击顶部导航栏的通知图标 @@ -67,6 +74,10 @@ SkillHub 提供了丰富的社交功能,让团队成员可以互动、分享 3. 点击通知跳转到相关页面 4. 标记为已读或全部已读 +通知列表和未读数每 10 秒通过普通 HTTP 请求刷新,并在窗口重新获得焦点时立即刷新。旧版 +`GET /api/v1/notifications/sse` 接口已移除;自建客户端应改为轮询 +`GET /api/v1/notifications` 和 `GET /api/v1/notifications/unread-count`。 + **查看我的收藏**: 1. 访问 `/dashboard/stars` @@ -125,6 +136,24 @@ GET /api/v1/me/stars?page=0&size=20 GET /api/v1/skills/{skillId}/rating ``` +**评价接口**: + +```bash +# 公开评价列表 +GET /api/v1/skills/{skillId}/reviews?page=0&size=20 + +# 查看、创建或修改自己的评价 +GET /api/v1/skills/{skillId}/reviews/me +PUT /api/v1/skills/{skillId}/reviews/me + +# 清空评价文字并保留星级评分 +DELETE /api/v1/skills/{skillId}/reviews/me + +# 技能管理员或超级管理员隐藏、恢复评价 +POST /api/v1/admin/skill-reviews/{reviewId}/hide +POST /api/v1/admin/skill-reviews/{reviewId}/restore +``` + **响应示例**: ```json { @@ -137,6 +166,8 @@ GET /api/v1/skills/{skillId}/rating > **评分规则**:每个用户对每个技能包只能评分一次,可以修改评分但不能删除。 +> **评价规则**:只有已发布技能可以评价;公开列表只显示可见评价。并发修改发生冲突时,刷新详情后重试。 + - **星标数量**:技能包的星标数会显示在搜索结果和详情页 - **平均评分**:技能包的平均评分会影响搜索排序 - **通知设置**:用户可以在设置中关闭某些类型的通知 diff --git a/docs/skillhub/quickstart.md b/docs/skillhub/quickstart.md index 141c6fef..69d7ef4e 100644 --- a/docs/skillhub/quickstart.md +++ b/docs/skillhub/quickstart.md @@ -128,22 +128,25 @@ curl -H "X-Mock-User-Id: local-admin" http://localhost:8080/api/v1/auth/me ## 安装 CLI 工具 -SkillHub 兼容 OpenClaw CLI,可以使用 `npx clawhub` 命令管理技能包: +推荐使用第一方 SkillHub CLI 管理技能包: ```bash -# 配置 SkillHub 注册中心地址 -export CLAWHUB_REGISTRY=http://localhost:8080 +# 安装并配置 SkillHub 注册中心地址 +npm install -g @astron-team/skillhub +export SKILLHUB_REGISTRY=http://localhost:8080 # 搜索技能包 -npx clawhub search email +skillhub search email # 安装技能包 -npx clawhub install my-skill +skillhub install my-skill # 发布技能包 -npx clawhub publish ./my-skill +skillhub publish ./my-skill --namespace my-team ``` +已有 ClawHub 工作流仍可通过兼容层进行搜索和安装;新流程及发布操作优先使用 SkillHub CLI。 + ## 发布第一个技能包 ### 使用 CLI 工具发布(推荐) @@ -164,13 +167,10 @@ my-skill/ ```bash # 配置注册中心 -export CLAWHUB_REGISTRY=http://localhost:8080 - -# 发布到默认命名空间 -npx clawhub publish ./my-skill +export SKILLHUB_REGISTRY=http://localhost:8080 # 发布到指定命名空间 -npx clawhub publish ./my-skill --namespace my-team +skillhub publish ./my-skill --namespace my-team ``` 3. **等待安全扫描** @@ -201,13 +201,13 @@ npx clawhub publish ./my-skill --namespace my-team ```bash # 搜索技能包 -npx clawhub search pdf +skillhub search pdf # 安装技能包 -npx clawhub install pdf-parser +skillhub install pdf-parser # 安装指定命名空间的技能包 -npx clawhub install my-team--pdf-parser +skillhub install pdf-parser --namespace my-team ``` ### 使用 Web UI diff --git a/docs/superpowers/specs/2026-03-19-notification-system-design.md b/docs/superpowers/specs/2026-03-19-notification-system-design.md index 33b1527a..d85ab977 100644 --- a/docs/superpowers/specs/2026-03-19-notification-system-design.md +++ b/docs/superpowers/specs/2026-03-19-notification-system-design.md @@ -2,11 +2,11 @@ ## Goal -Build an independent in-app notification subsystem for SkillHub that delivers real-time notifications for skill lifecycle events (publish, review, promotion, report), with SSE push, user preference control, and extensibility for future third-party channels. +Build an independent in-app notification subsystem for SkillHub that delivers near-real-time notifications for skill lifecycle events (publish, review, promotion, report), with HTTP polling, user preference control, and extensibility for future third-party channels. ## Scope -- **In scope**: In-app notifications, SSE real-time push, notification preferences (category × channel), bell icon + dropdown + notification page, data cleanup +- **In scope**: In-app notifications, 10-second HTTP polling, notification preferences (category × channel), bell icon + dropdown + notification page, data cleanup - **Out of scope**: External channels (email, Feishu, DingTalk), migration of existing governance notifications, external webhook delivery ## Architecture @@ -22,13 +22,11 @@ Domain Events (existing + new) └── NotificationModule (NEW) ├── NotificationEventListener ├── RecipientResolver - ├── NotificationPreferenceService (filter) - ├── NotificationDispatcher (channel routing) - ├── NotificationService (persist) - └── SseEmitterManager (push) + ├── NotificationPreferenceService (preference CRUD) + └── NotificationService (preference filter + persist + HTTP reads) ``` -The notification module consumes domain events via `@TransactionalEventListener(phase = AFTER_COMMIT)` + `@Async("skillhubEventExecutor")`, following the same pattern as existing listeners. The async executor pool (max 4 threads) is sufficient for the added load since notification processing is lightweight (DB insert + SSE push). +The notification module consumes domain events via `@TransactionalEventListener(phase = AFTER_COMMIT)` + `@Async("skillhubEventExecutor")`, following the same pattern as existing listeners. The async executor pool (max 4 threads) is sufficient for the added load since notification processing is a lightweight database insert. Clients discover persisted changes through HTTP polling. ## Data Model @@ -163,30 +161,24 @@ skillhub-notification/ -- new module (depends on: skillhub-do │ ├── NotificationPreference.java │ ├── NotificationRepository.java │ └── NotificationPreferenceRepository.java -├── service/ -│ ├── NotificationService.java -- CRUD: create, list, mark read, batch read, unread count -│ ├── NotificationPreferenceService.java -- preference CRUD + default fallback -│ └── NotificationDispatcher.java -- route by channel (currently IN_APP only) -└── sse/ - └── SseEmitterManager.java -- manage SSE connections: register, push, heartbeat, cleanup +└── service/ + ├── NotificationService.java -- apply preference, create, list, mark read, batch read, unread count + └── NotificationPreferenceService.java -- preference CRUD + default fallback skillhub-app/ -- existing module └── listener/ - ├── NotificationEventListener.java -- consume domain events, call RecipientResolver + Dispatcher + ├── NotificationEventListener.java -- consume domain events, call RecipientResolver + NotificationService └── RecipientResolver.java -- resolve recipient list per event type (needs auth + domain repos) ``` -## SSE Real-Time Push +## HTTP Polling -- Endpoint: `GET /api/notifications/sse` -- `SseEmitterManager` uses `ConcurrentHashMap>` (thread-safe for concurrent tab open/close) -- Per-user connection limit: max 5 emitters (reject new connections beyond limit) -- Global connection limit: max 1000 emitters (configurable, reject with 503 when exceeded) -- SseEmitter timeout: 60s, browser `EventSource` auto-reconnects -- Heartbeat: `:ping` every 30s to prevent proxy/LB disconnection -- On emitter complete/timeout/error: auto-remove from map -- Push failure: silent ignore (notification already persisted, visible on refresh) -- On `EventSource` reconnect: frontend fetches unread count to sync badge +- The global bell polls `GET /api/notifications/unread-count` every 10 seconds while a user is authenticated. +- An active dropdown or notification page polls its paginated `GET /api/notifications` query every 10 seconds. +- Polling pauses while the browser tab is in the background. +- Window focus and network reconnect trigger a fresh request. +- Poll responses are authoritative; the unread badge uses the server count instead of incrementing a client-side event counter. +- Closing the dropdown unmounts its list query, so the full notification list is not polled when it is not visible. ## API Design @@ -195,8 +187,6 @@ GET /api/notifications -- List (paginated + category filter) GET /api/notifications/unread-count -- Unread count (for bell badge) PUT /api/notifications/{id}/read -- Mark single as read PUT /api/notifications/read-all -- Mark all as read -GET /api/notifications/sse -- SSE connection - GET /api/notification-preferences -- Get current user preferences PUT /api/notification-preferences -- Batch update preferences ``` @@ -208,10 +198,12 @@ Response format follows existing SkillHub API conventions (code + data wrapper). ### Bell Component (global nav bar) - Bell icon in nav bar, left of user avatar - Red badge with unread count (> 99 shows "99+") +- Polls the unread count over HTTP every 10 seconds while the tab is visible - Click to expand dropdown ### Dropdown List - Shows latest 5 notifications +- Polls the visible list over HTTP every 10 seconds - Each item: title + relative time ("3 minutes ago") - Click item → navigate to entity page + mark as read - Footer: "View all notifications" link @@ -219,6 +211,7 @@ Response format follows existing SkillHub API conventions (code + data wrapper). ### Notification Page (`/dashboard/notifications`) - Full notification list with pagination +- Polls the visible page over HTTP every 10 seconds - Tab filter by category: All / Publish / Review / Promotion / Report - Batch mark all as read - Click to navigate @@ -242,8 +235,6 @@ Response format follows existing SkillHub API conventions (code + data wrapper). ```yaml skillhub: notification: - sse-timeout: 60s - sse-heartbeat: 30s cleanup: read-retention-days: 30 unread-retention-days: 90 @@ -260,6 +251,5 @@ skillhub: ## Extensibility - New event types: add domain event record + mapping in `NotificationEventListener` -- New channels: add enum value to `NotificationChannel` + implement channel-specific dispatcher - Third-party integrations: add new `@TransactionalEventListener` beans that consume the same domain events - Preference table already supports category × channel granularity, no schema change needed diff --git a/scanner/Dockerfile b/scanner/Dockerfile index f341893c..7d937a94 100644 --- a/scanner/Dockerfile +++ b/scanner/Dockerfile @@ -5,6 +5,7 @@ ARG SKILL_SCANNER_VERSION=1.0.2 WORKDIR /app COPY backports/apply_1_0_2_llm_base_url_backport.py /tmp/apply_1_0_2_llm_base_url_backport.py +COPY skillhub_scanner_app.py /app/skillhub_scanner_app.py RUN pip install --no-cache-dir \ "cisco-ai-skill-scanner==${SKILL_SCANNER_VERSION}" \ @@ -23,4 +24,4 @@ EXPOSE 8000 HEALTHCHECK --interval=10s --timeout=3s \ CMD wget -qO- http://127.0.0.1:8000/health || exit 1 -CMD ["skill-scanner-api", "--host", "0.0.0.0", "--port", "8000"] +CMD ["uvicorn", "skillhub_scanner_app:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/scanner/docs/configuration.md b/scanner/docs/configuration.md index 2544aed2..c141d577 100644 --- a/scanner/docs/configuration.md +++ b/scanner/docs/configuration.md @@ -21,6 +21,7 @@ skillhub: enabled: ${SKILLHUB_SECURITY_SCANNER_ENABLED:true} base-url: ${SKILLHUB_SECURITY_SCANNER_URL:http://localhost:8000} mode: ${SKILLHUB_SECURITY_SCANNER_MODE:local} + read-timeout-ms: ${SKILLHUB_SECURITY_SCANNER_READ_TIMEOUT:900000} # 分析器配置 analyzers: @@ -39,6 +40,10 @@ skillhub: preset: ${SKILLHUB_SCANNER_POLICY_PRESET:balanced} custom-policy-path: ${SKILLHUB_SCANNER_CUSTOM_POLICY_PATH:} fail-on-severity: ${SKILLHUB_SCANNER_FAIL_ON_SEVERITY:high} + + stream: + # Keep this greater than scanner.read-timeout-ms. + reclaim-min-idle: ${SKILLHUB_SCAN_STREAM_RECLAIM_MIN_IDLE:PT16M} ``` ## 配置项详解 @@ -110,6 +115,15 @@ mode: upload mode: upload ``` +#### 扫描超时与并发 + +- `SKILLHUB_SECURITY_SCANNER_READ_TIMEOUT`:服务端等待单次扫描的毫秒数,默认 15 分钟。 +- `SKILLHUB_SCAN_STREAM_RECLAIM_MIN_IDLE`:未确认任务允许被恢复的等待时间,应大于扫描超时,默认 16 分钟。 +- `SKILLHUB_SCANNER_MAX_CONCURRENT_SCANS`:Scanner 容器内的最大并发扫描数,默认 `1`;超出的请求返回 HTTP 503,由待处理消息稍后重试。 +- `SKILLHUB_SCANNER_HARD_TIMEOUT_SECONDS`:Scanner 单次工作的硬上限,默认 930 秒。超时后进程以状态码 `124` 退出,由 Compose/Kubernetes 重启;默认关系为服务端等待 900 秒 < Scanner 硬上限 930 秒 < Redis 恢复等待 960 秒。进程退出会使同容器内其他扫描稍后重试,因此建议保持默认并发数 `1`。 + +Scanner 超时或暂时不可用(包括 429、5xx)时,版本不会进入 `SCAN_FAILED`。任务保留为待处理,版本保持 `SCANNING`,待 Scanner 恢复后自动继续。确定性的 4xx 包校验错误仍采用有限次数重试,最终可以进入 `SCAN_FAILED`。某个包若稳定触发 Scanner 内部 500,会保持 `SCANNING` 等待 Scanner 修复,而不会被误判为包本身不合格。 + --- ### 2. 分析器配置 diff --git a/scanner/skillhub_scanner_app.py b/scanner/skillhub_scanner_app.py new file mode 100644 index 00000000..17ab130c --- /dev/null +++ b/scanner/skillhub_scanner_app.py @@ -0,0 +1,87 @@ +"""Runtime safeguards around the upstream Cisco Skill Scanner ASGI application.""" + +import asyncio +import logging +import os +import shutil +import tempfile +from pathlib import Path +from typing import NoReturn + +from fastapi import Request +from fastapi.responses import JSONResponse +from skill_scanner.api.api import app + + +_MAX_CONCURRENT_SCANS = max(1, int(os.getenv("SKILLHUB_SCANNER_MAX_CONCURRENT_SCANS", "1"))) +_HARD_TIMEOUT_SECONDS = max(1, int(os.getenv("SKILLHUB_SCANNER_HARD_TIMEOUT_SECONDS", "930"))) +_active_scans = 0 +_active_scans_guard = asyncio.Lock() +_SCAN_PATHS = {"/scan", "/scan-upload"} +_log = logging.getLogger(__name__) + + +def _cleanup_stale_scan_directories(temp_root: Path | None = None) -> None: + """Remove incomplete upstream extraction directories left by a process restart.""" + root = temp_root or Path(tempfile.gettempdir()) + for candidate in root.glob("skill_scanner_*"): + if not candidate.is_dir(): + continue + try: + shutil.rmtree(candidate) + except OSError as error: + _log.warning("Could not remove stale scanner directory %s: %s", candidate, error) + + +def _restart_after_hard_timeout(request_path: str) -> NoReturn: + """Terminate the single-scan worker so the container runtime can recover it.""" + _log.critical( + "Security scan exceeded the %s second hard timeout: path=%s; restarting scanner", + _HARD_TIMEOUT_SECONDS, + request_path, + ) + logging.shutdown() + os._exit(124) + + +async def _await_scan_until(scan_task: asyncio.Task, deadline: float, request_path: str): + remaining = deadline - asyncio.get_running_loop().time() + if remaining <= 0: + _restart_after_hard_timeout(request_path) + try: + return await asyncio.wait_for(asyncio.shield(scan_task), timeout=remaining) + except asyncio.TimeoutError: + _restart_after_hard_timeout(request_path) + + +app.router.add_event_handler("startup", _cleanup_stale_scan_directories) + + +@app.middleware("http") +async def limit_concurrent_scans(request: Request, call_next): + """Reject excess scan work so timed-out client retries cannot multiply memory use.""" + global _active_scans + if request.method != "POST" or request.url.path not in _SCAN_PATHS: + return await call_next(request) + + async with _active_scans_guard: + if _active_scans >= _MAX_CONCURRENT_SCANS: + return JSONResponse( + status_code=503, + content={"detail": "Scanner is busy; retry later"}, + headers={"Retry-After": "30"}, + ) + _active_scans += 1 + + scan_task = asyncio.create_task(call_next(request)) + deadline = asyncio.get_running_loop().time() + _HARD_TIMEOUT_SECONDS + try: + # Keep the capacity slot until upstream work really ends, even if the HTTP client + # disconnects while the scanner's worker thread is still running. + return await _await_scan_until(scan_task, deadline, request.url.path) + except asyncio.CancelledError: + await _await_scan_until(scan_task, deadline, request.url.path) + raise + finally: + async with _active_scans_guard: + _active_scans -= 1 diff --git a/scanner/tests/test_skillhub_scanner_app.py b/scanner/tests/test_skillhub_scanner_app.py new file mode 100644 index 00000000..fd58469a --- /dev/null +++ b/scanner/tests/test_skillhub_scanner_app.py @@ -0,0 +1,131 @@ +import asyncio +import importlib.util +import sys +import tempfile +import types +import unittest +from pathlib import Path +from unittest.mock import patch + + +class _FakeRouter: + def __init__(self): + self.handlers = [] + + def add_event_handler(self, _event, _handler): + self.handlers.append((_event, _handler)) + + +class _FakeApp: + def __init__(self): + self.router = _FakeRouter() + + def middleware(self, _kind): + return lambda function: function + + +class _FakeResponse: + def __init__(self, status_code, content, headers): + self.status_code = status_code + self.content = content + self.headers = headers + + +class _Request: + method = "POST" + url = types.SimpleNamespace(path="/scan-upload") + + +def _load_module(): + fastapi = types.ModuleType("fastapi") + fastapi.Request = object + responses = types.ModuleType("fastapi.responses") + responses.JSONResponse = _FakeResponse + api = types.ModuleType("skill_scanner.api.api") + api.app = _FakeApp() + stubs = { + "fastapi": fastapi, + "fastapi.responses": responses, + "skill_scanner": types.ModuleType("skill_scanner"), + "skill_scanner.api": types.ModuleType("skill_scanner.api"), + "skill_scanner.api.api": api, + } + with patch.dict(sys.modules, stubs): + module_path = Path(__file__).parents[1] / "skillhub_scanner_app.py" + spec = importlib.util.spec_from_file_location("skillhub_scanner_app_under_test", module_path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +class SkillHubScannerAppTest(unittest.IsolatedAsyncioTestCase): + async def asyncSetUp(self): + self.module = _load_module() + + async def test_excess_scan_is_rejected(self): + self.module._active_scans = 1 + + response = await self.module.limit_concurrent_scans(_Request(), lambda _request: None) + + self.assertEqual(503, response.status_code) + self.assertEqual("30", response.headers["Retry-After"]) + + async def test_client_disconnect_keeps_slot_until_scan_finishes(self): + release = asyncio.Event() + + async def scan(_request): + await release.wait() + return "done" + + request_task = asyncio.create_task(self.module.limit_concurrent_scans(_Request(), scan)) + await asyncio.sleep(0) + request_task.cancel() + await asyncio.sleep(0) + + self.assertEqual(1, self.module._active_scans) + response = await self.module.limit_concurrent_scans(_Request(), scan) + self.assertEqual(503, response.status_code) + + release.set() + with self.assertRaises(asyncio.CancelledError): + await request_task + self.assertEqual(0, self.module._active_scans) + + async def test_hard_timeout_requests_process_restart(self): + self.module._HARD_TIMEOUT_SECONDS = 0.01 + + async def stuck_scan(_request): + await asyncio.Event().wait() + + with patch.object( + self.module, + "_restart_after_hard_timeout", + side_effect=RuntimeError("restart requested")) as restart: + with self.assertRaisesRegex(RuntimeError, "restart requested"): + await self.module.limit_concurrent_scans(_Request(), stuck_scan) + + restart.assert_called_once_with("/scan-upload") + self.assertEqual(0, self.module._active_scans) + + async def test_startup_cleanup_removes_only_scanner_directories(self): + with tempfile.TemporaryDirectory() as temp_root: + root = Path(temp_root) + stale = root / "skill_scanner_abcd" + unrelated = root / "skillhub-data" + stale.mkdir() + unrelated.mkdir() + + self.module._cleanup_stale_scan_directories(root) + + self.assertFalse(stale.exists()) + self.assertTrue(unrelated.exists()) + + async def test_startup_cleanup_is_registered_on_the_upstream_router(self): + self.assertEqual( + [("startup", self.module._cleanup_stale_scan_directories)], + self.module.app.router.handlers, + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/promotion-smoke-test.sh b/scripts/promotion-smoke-test.sh index b65e23ef..0a45ce38 100755 --- a/scripts/promotion-smoke-test.sh +++ b/scripts/promotion-smoke-test.sh @@ -8,7 +8,7 @@ FAIL=0 USER_COOKIE="$(mktemp)" ADMIN_COOKIE="$(mktemp)" WORK_DIR="$(mktemp -d)" -SLUG="psmoke$(date +%s)" +SLUG="psmoke$(date +%s)${RANDOM}" cleanup() { rm -f "$USER_COOKIE" "$ADMIN_COOKIE" @@ -89,54 +89,132 @@ if [[ -z "$USER_CSRF" || -z "$ADMIN_CSRF" ]]; then exit 1 fi -GLOBAL_RESPONSE="$(curl -sS -H "X-Mock-User-Id: local-user" -b "$USER_COOKIE" -c "$USER_COOKIE" \ +GLOBAL_RESPONSE="$(curl -sS -H "X-Mock-User-Id: local-admin" -b "$ADMIN_COOKIE" -c "$ADMIN_COOKIE" \ "$BASE_URL/api/web/namespaces/global")" assert_code "Global namespace detail is available" "$GLOBAL_RESPONSE" "0" GLOBAL_NAMESPACE_ID="$(json_field "$GLOBAL_RESPONSE" "data.id")" -CREATE_NAMESPACE_RESPONSE="$(curl -sS -H "X-Mock-User-Id: local-user" -b "$USER_COOKIE" -c "$USER_COOKIE" \ - -H "X-XSRF-TOKEN: $USER_CSRF" \ +CREATE_NAMESPACE_RESPONSE="$(curl -sS -H "X-Mock-User-Id: local-admin" -b "$ADMIN_COOKIE" -c "$ADMIN_COOKIE" \ + -H "X-XSRF-TOKEN: $ADMIN_CSRF" \ -H "Content-Type: application/json" \ -X POST "$BASE_URL/api/web/namespaces" \ -d "{\"slug\":\"$SLUG\",\"displayName\":\"Promotion Smoke $SLUG\",\"description\":\"promotion smoke test\"}")" -assert_code "Owner can create promotion smoke namespace" "$CREATE_NAMESPACE_RESPONSE" "0" +assert_code "Admin can create promotion smoke namespace" "$CREATE_NAMESPACE_RESPONSE" "0" NAMESPACE_ID="$(json_field "$CREATE_NAMESPACE_RESPONSE" "data.id")" -cat > "$WORK_DIR/SKILL.md" <<'EOF' +ADD_MEMBER_RESPONSE="$(curl -sS -H "X-Mock-User-Id: local-admin" -b "$ADMIN_COOKIE" -c "$ADMIN_COOKIE" \ + -H "X-XSRF-TOKEN: $ADMIN_CSRF" \ + -H "Content-Type: application/json" \ + -X POST "$BASE_URL/api/web/namespaces/$SLUG/members" \ + -d '{"userId":"local-user","role":"MEMBER"}')" +assert_code "Admin can add the regular user as a namespace member" "$ADD_MEMBER_RESPONSE" "0" + +cat > "$WORK_DIR/SKILL.md" <skillhub-app + + 1.21.4 + + org.springframework.boot spring-boot-starter-web + + org.springframework.boot + spring-boot-starter-validation + org.springframework.boot spring-boot-starter-actuator @@ -121,6 +129,16 @@ h2 test + + org.testcontainers + junit-jupiter + test + + + org.testcontainers + postgresql + test + diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/RedisStreamConfig.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/RedisStreamConfig.java index 87ca0762..ccb1356e 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/RedisStreamConfig.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/RedisStreamConfig.java @@ -8,12 +8,13 @@ import com.iflytek.skillhub.observability.MessageObservationSupport; import com.iflytek.skillhub.storage.ObjectStorageService; import com.iflytek.skillhub.stream.RedissonScanTaskProducer; import com.iflytek.skillhub.stream.ScanTaskConsumer; +import java.time.Clock; +import java.time.Duration; import org.redisson.api.RedissonClient; import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; -import java.time.Duration; @Configuration @ConditionalOnProperty(prefix = "skillhub.security.scanner", name = "enabled", havingValue = "true") @@ -28,7 +29,7 @@ public class RedisStreamConfig { @Value("${skillhub.security.stream.reclaim-enabled:true}") private boolean reclaimEnabled; - @Value("${skillhub.security.stream.reclaim-min-idle:PT2M}") + @Value("${skillhub.security.stream.reclaim-min-idle:PT16M}") private Duration reclaimMinIdle; @Value("${skillhub.security.stream.reclaim-batch-size:20}") @@ -37,6 +38,12 @@ public class RedisStreamConfig { @Value("${skillhub.security.stream.reclaim-interval:PT30S}") private Duration reclaimInterval; + @Value("${skillhub.security.scanner.retry-max-attempts:3}") + private int maxRetryAttempts; + + @Value("${skillhub.security.stream.max-unavailable-age:PT1H}") + private Duration maxUnavailableAge; + @Bean public RedissonScanTaskProducer redisScanTaskProducer( RedissonClient redissonClient, @@ -52,6 +59,7 @@ public class RedisStreamConfig { SkillVersionRepository skillVersionRepository, ScanTaskProducer scanTaskProducer, ObjectStorageService objectStorageService, + Clock clock, MessageObservationSupport messageObservationSupport) { return new ScanTaskConsumer( redissonClient, @@ -66,6 +74,9 @@ public class RedisStreamConfig { reclaimMinIdle, reclaimBatchSize, reclaimInterval, + maxRetryAttempts, + maxUnavailableAge, + clock, messageObservationSupport ); } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/SessionRecoveryConfig.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/SessionRecoveryConfig.java new file mode 100644 index 00000000..ea7a5371 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/SessionRecoveryConfig.java @@ -0,0 +1,26 @@ +package com.iflytek.skillhub.config; + +import com.iflytek.skillhub.auth.session.CorruptSessionRemover; +import java.util.List; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.core.StringRedisTemplate; + +/** + * Connects the authentication module's recovery boundary to Spring Session storage. + */ +@Configuration +public class SessionRecoveryConfig { + + @Bean + CorruptSessionRemover corruptSessionRemover( + StringRedisTemplate redisTemplate, + @Value("${spring.session.redis.namespace:spring:session}") String namespace) { + String keyPrefix = namespace.endsWith(":") ? namespace : namespace + ":"; + return sessionId -> redisTemplate.delete(List.of( + keyPrefix + "sessions:" + sessionId, + keyPrefix + "sessions:expires:" + sessionId + )); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/SkillScannerProperties.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/SkillScannerProperties.java index bb1f0f87..12080e96 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/SkillScannerProperties.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/SkillScannerProperties.java @@ -12,7 +12,7 @@ public class SkillScannerProperties { private String healthPath = "/health"; private String scanPath = "/scan-upload"; private int connectTimeoutMs = 5000; - private int readTimeoutMs = 300000; + private int readTimeoutMs = 900000; private int retryMaxAttempts = 3; private String mode = "local"; private Analyzers analyzers = new Analyzers(); diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/WebMvcRateLimitConfig.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/WebMvcRateLimitConfig.java index 431b46a8..0a7bfd93 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/WebMvcRateLimitConfig.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/WebMvcRateLimitConfig.java @@ -1,9 +1,7 @@ package com.iflytek.skillhub.config; -import com.iflytek.skillhub.notification.sse.SseEmitterManager; import com.iflytek.skillhub.ratelimit.RateLimitInterceptor; import org.springframework.context.annotation.Configuration; -import org.springframework.web.servlet.config.annotation.AsyncSupportConfigurer; import org.springframework.web.servlet.config.annotation.InterceptorRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; @@ -24,11 +22,4 @@ public class WebMvcRateLimitConfig implements WebMvcConfigurer { registry.addInterceptor(rateLimitInterceptor) .addPathPatterns("/api/**"); } - - @Override - public void configureAsyncSupport(AsyncSupportConfigurer configurer) { - // Keep MVC async timeouts above the SSE emitter timeout so EventSource - // connections are not forcibly torn down every few seconds. - configurer.setDefaultTimeout(SseEmitterManager.defaultTimeoutMillis()); - } } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/AdminSkillReviewController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/AdminSkillReviewController.java new file mode 100644 index 00000000..791da2f2 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/AdminSkillReviewController.java @@ -0,0 +1,59 @@ +package com.iflytek.skillhub.controller.admin; + +import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; +import com.iflytek.skillhub.controller.BaseApiController; +import com.iflytek.skillhub.dto.ApiResponse; +import com.iflytek.skillhub.dto.ApiResponseFactory; +import com.iflytek.skillhub.dto.SkillReviewModerationRequest; +import com.iflytek.skillhub.dto.SkillReviewResponse; +import com.iflytek.skillhub.service.AuditRequestContext; +import com.iflytek.skillhub.service.SkillReviewAppService; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.Valid; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/v1/admin/skill-reviews") +@PreAuthorize("hasAnyRole('SKILL_ADMIN', 'SUPER_ADMIN')") +public class AdminSkillReviewController extends BaseApiController { + + private final SkillReviewAppService reviewAppService; + + public AdminSkillReviewController(ApiResponseFactory responseFactory, + SkillReviewAppService reviewAppService) { + super(responseFactory); + this.reviewAppService = reviewAppService; + } + + @PostMapping("/{reviewId}/hide") + public ApiResponse hide( + @PathVariable Long reviewId, + @Valid @RequestBody(required = false) SkillReviewModerationRequest request, + @AuthenticationPrincipal PlatformPrincipal principal, + HttpServletRequest httpRequest) { + return ok("response.success.updated", reviewAppService.hide( + reviewId, + principal.userId(), + request != null ? request.reason() : null, + AuditRequestContext.from(httpRequest) + )); + } + + @PostMapping("/{reviewId}/restore") + public ApiResponse restore( + @PathVariable Long reviewId, + @AuthenticationPrincipal PlatformPrincipal principal, + HttpServletRequest httpRequest) { + return ok("response.success.updated", reviewAppService.restore( + reviewId, + principal.userId(), + AuditRequestContext.from(httpRequest) + )); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/NotificationController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/NotificationController.java index ec62ebb9..cb8f7d4c 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/NotificationController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/NotificationController.java @@ -8,7 +8,6 @@ import com.iflytek.skillhub.dto.*; import com.iflytek.skillhub.notification.domain.Notification; import com.iflytek.skillhub.notification.domain.NotificationCategory; import com.iflytek.skillhub.notification.service.NotificationService; -import com.iflytek.skillhub.notification.sse.SseEmitterManager; import java.util.Collections; import java.util.Map; import jakarta.validation.constraints.Max; @@ -16,10 +15,8 @@ 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; @RestController @Validated @@ -27,16 +24,13 @@ import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; public class NotificationController extends BaseApiController { private final NotificationService notificationService; - private final SseEmitterManager sseEmitterManager; private final ObjectMapper objectMapper; public NotificationController(NotificationService notificationService, - SseEmitterManager sseEmitterManager, ObjectMapper objectMapper, ApiResponseFactory responseFactory) { super(responseFactory); this.notificationService = notificationService; - this.sseEmitterManager = sseEmitterManager; this.objectMapper = objectMapper; } @@ -79,11 +73,6 @@ public class NotificationController extends BaseApiController { return ok("response.success.deleted", null); } - @GetMapping(value = "/sse", produces = MediaType.TEXT_EVENT_STREAM_VALUE) - public SseEmitter sse(@RequestAttribute("userId") String userId) { - return sseEmitterManager.register(userId); - } - private NotificationResponse toResponse(Notification n) { NotificationTarget target = resolveTarget(n); return new NotificationResponse( diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/ReviewController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/ReviewController.java index cbca1ada..8923664b 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/ReviewController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/ReviewController.java @@ -8,11 +8,13 @@ import com.iflytek.skillhub.dto.ApiResponseFactory; import com.iflytek.skillhub.dto.PageResponse; import com.iflytek.skillhub.dto.ReviewActionRequest; import com.iflytek.skillhub.dto.ReviewSkillDetailResponse; +import com.iflytek.skillhub.dto.ReviewProgressPageResponse; import com.iflytek.skillhub.dto.ReviewTaskRequest; import com.iflytek.skillhub.dto.ReviewTaskResponse; import com.iflytek.skillhub.service.AuditRequestContext; import com.iflytek.skillhub.service.GovernanceWorkflowAppService; import jakarta.servlet.http.HttpServletRequest; +import java.util.List; import java.util.Map; import org.springframework.core.io.InputStreamResource; import org.springframework.http.HttpHeaders; @@ -136,6 +138,38 @@ public class ReviewController extends BaseApiController { return ok("response.success.read", governanceWorkflowAppService.listMyReviewSubmissions(page, size, userId)); } + @GetMapping("/my-progress") + public ApiResponse listMyProgress( + @RequestParam(required = false) String status, + @RequestParam(defaultValue = "") String q, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size, + @RequestAttribute("userId") String userId) { + return ok( + "response.success.read", + governanceWorkflowAppService.listMyReviewProgress(status, q, page, size, userId) + ); + } + + @GetMapping("/my-progress/{id}/attempts") + public ApiResponse> listMyAttempts( + @PathVariable Long id, + @RequestAttribute("userId") String userId) { + return ok("response.success.read", governanceWorkflowAppService.listMyReviewAttempts(id, userId)); + } + + @GetMapping("/{id}/attempts") + public ApiResponse> listReviewAttempts( + @PathVariable Long id, + @RequestAttribute("userId") String userId, + @RequestAttribute(value = "userNsRoles", required = false) + Map userNsRoles) { + return ok( + "response.success.read", + governanceWorkflowAppService.listReviewAttempts(id, userId, userNsRoles) + ); + } + @GetMapping("/{id}") public ApiResponse getReviewDetail(@PathVariable Long id, @RequestAttribute("userId") String userId, diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SecurityAuditController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SecurityAuditController.java index f763424f..9ea74d6d 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SecurityAuditController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SecurityAuditController.java @@ -19,9 +19,14 @@ import com.iflytek.skillhub.domain.skill.VisibilityChecker; import com.iflytek.skillhub.dto.ApiResponse; import com.iflytek.skillhub.dto.ApiResponseFactory; import com.iflytek.skillhub.dto.SecurityAuditResponse; +import com.iflytek.skillhub.dto.SkillLifecycleMutationResponse; +import com.iflytek.skillhub.service.AuditRequestContext; +import com.iflytek.skillhub.service.SecurityScanRetryAppService; +import jakarta.servlet.http.HttpServletRequest; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestAttribute; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; @@ -41,19 +46,40 @@ public class SecurityAuditController extends BaseApiController { private final SkillVersionRepository skillVersionRepository; private final VisibilityChecker visibilityChecker; private final ObjectMapper objectMapper; + private final SecurityScanRetryAppService securityScanRetryAppService; public SecurityAuditController(SecurityAuditRepository securityAuditRepository, SkillRepository skillRepository, SkillVersionRepository skillVersionRepository, VisibilityChecker visibilityChecker, ApiResponseFactory responseFactory, - ObjectMapper objectMapper) { + ObjectMapper objectMapper, + SecurityScanRetryAppService securityScanRetryAppService) { super(responseFactory); this.securityAuditRepository = securityAuditRepository; this.skillRepository = skillRepository; this.skillVersionRepository = skillVersionRepository; this.visibilityChecker = visibilityChecker; this.objectMapper = objectMapper; + this.securityScanRetryAppService = securityScanRetryAppService; + } + + @PostMapping("/retry") + public ApiResponse retrySecurityScan( + @PathVariable Long skillId, + @PathVariable Long versionId, + @AuthenticationPrincipal PlatformPrincipal principal, + @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles, + HttpServletRequest request) { + SkillLifecycleMutationResponse result = securityScanRetryAppService.retry( + skillId, + versionId, + principal.userId(), + principal.platformRoles(), + userNsRoles, + AuditRequestContext.from(request) + ); + return ok("security_audit.retry.started", result); } @GetMapping @@ -121,6 +147,7 @@ public class SecurityAuditController extends BaseApiController { audit.getFindingsCount(), deserializeFindings(audit.getFindings()), audit.getScanDurationSeconds(), + audit.getFailureReason(), audit.getScannedAt(), audit.getCreatedAt() ); diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillReviewController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillReviewController.java new file mode 100644 index 00000000..b29a1d9f --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillReviewController.java @@ -0,0 +1,95 @@ +package com.iflytek.skillhub.controller.portal; + +import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; +import com.iflytek.skillhub.controller.BaseApiController; +import com.iflytek.skillhub.domain.namespace.NamespaceRole; +import com.iflytek.skillhub.dto.ApiResponse; +import com.iflytek.skillhub.dto.ApiResponseFactory; +import com.iflytek.skillhub.dto.PageResponse; +import com.iflytek.skillhub.dto.SkillReviewMeResponse; +import com.iflytek.skillhub.dto.SkillReviewRequest; +import com.iflytek.skillhub.dto.SkillReviewResponse; +import com.iflytek.skillhub.service.SkillReviewAppService; +import jakarta.validation.Valid; +import java.util.Map; +import java.util.Set; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestAttribute; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping({"/api/v1/skills", "/api/web/skills"}) +public class SkillReviewController extends BaseApiController { + + private final SkillReviewAppService reviewAppService; + + public SkillReviewController(ApiResponseFactory responseFactory, + SkillReviewAppService reviewAppService) { + super(responseFactory); + this.reviewAppService = reviewAppService; + } + + @GetMapping("/{skillId}/reviews") + public ApiResponse> list( + @PathVariable Long skillId, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size, + @AuthenticationPrincipal PlatformPrincipal principal, + @RequestAttribute(value = "userNsRoles", required = false) Map namespaceRoles) { + return ok("response.success.read", reviewAppService.list( + skillId, + principal != null ? principal.userId() : null, + namespaceRoles, + roles(principal), + page, + size + )); + } + + @GetMapping("/{skillId}/reviews/me") + public ApiResponse getMine( + @PathVariable Long skillId, + @AuthenticationPrincipal PlatformPrincipal principal, + @RequestAttribute(value = "userNsRoles", required = false) Map namespaceRoles) { + return ok("response.success.read", reviewAppService.getMine( + skillId, principal.userId(), namespaceRoles, roles(principal))); + } + + @PutMapping("/{skillId}/reviews/me") + public ApiResponse upsert( + @PathVariable Long skillId, + @Valid @RequestBody SkillReviewRequest request, + @AuthenticationPrincipal PlatformPrincipal principal, + @RequestAttribute(value = "userNsRoles", required = false) Map namespaceRoles) { + return ok("response.success.updated", reviewAppService.upsert( + skillId, + principal.userId(), + request.score(), + request.reviewText(), + namespaceRoles, + roles(principal) + )); + } + + @DeleteMapping("/{skillId}/reviews/me") + public ApiResponse clear( + @PathVariable Long skillId, + @AuthenticationPrincipal PlatformPrincipal principal, + @RequestAttribute(value = "userNsRoles", required = false) Map namespaceRoles) { + return ok("response.success.updated", reviewAppService.clear( + skillId, principal.userId(), namespaceRoles, roles(principal))); + } + + private Set roles(PlatformPrincipal principal) { + return principal != null && principal.platformRoles() != null + ? principal.platformRoles() + : Set.of(); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/ReviewProgressPageResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/ReviewProgressPageResponse.java new file mode 100644 index 00000000..dfae6715 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/ReviewProgressPageResponse.java @@ -0,0 +1,14 @@ +package com.iflytek.skillhub.dto; + +import java.util.List; + +/** + * Author-facing review progress page with search-scoped current-status totals. + */ +public record ReviewProgressPageResponse( + List items, + long total, + int page, + int size, + ReviewProgressStatusCounts statusCounts +) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/ReviewProgressResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/ReviewProgressResponse.java new file mode 100644 index 00000000..f69885b9 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/ReviewProgressResponse.java @@ -0,0 +1,19 @@ +package com.iflytek.skillhub.dto; + +import java.time.Instant; + +/** + * Author-facing summary for one skill version's review attempts. + */ +public record ReviewProgressResponse( + Long latestReviewTaskId, + Long skillId, + String namespace, + String skillSlug, + String skillVersion, + String latestStatus, + String latestReviewComment, + Instant latestSubmittedAt, + Instant latestReviewedAt, + long attemptCount +) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/ReviewProgressStatusCounts.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/ReviewProgressStatusCounts.java new file mode 100644 index 00000000..a94361b8 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/ReviewProgressStatusCounts.java @@ -0,0 +1,10 @@ +package com.iflytek.skillhub.dto; + +/** + * Current review-status totals for the author's grouped skill-version progress. + */ +public record ReviewProgressStatusCounts( + long pending, + long approved, + long rejected +) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SecurityAuditResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SecurityAuditResponse.java index f4efeb9e..4e5b09c4 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SecurityAuditResponse.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SecurityAuditResponse.java @@ -16,6 +16,7 @@ public record SecurityAuditResponse( Integer findingsCount, List findings, Double scanDurationSeconds, + String failureReason, Instant scannedAt, Instant createdAt ) { diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillReviewMeResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillReviewMeResponse.java new file mode 100644 index 00000000..ecafd42a --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillReviewMeResponse.java @@ -0,0 +1,19 @@ +package com.iflytek.skillhub.dto; + +import java.time.Instant; + +public record SkillReviewMeResponse( + boolean rated, + short score, + boolean reviewed, + Long reviewId, + String reviewText, + String status, + String moderationReason, + Instant createdAt, + Instant updatedAt +) { + public static SkillReviewMeResponse empty() { + return new SkillReviewMeResponse(false, (short) 0, false, null, null, null, null, null, null); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillReviewModerationRequest.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillReviewModerationRequest.java new file mode 100644 index 00000000..f1ebc960 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillReviewModerationRequest.java @@ -0,0 +1,7 @@ +package com.iflytek.skillhub.dto; + +import jakarta.validation.constraints.Size; + +public record SkillReviewModerationRequest( + @Size(max = 500) String reason +) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillReviewRequest.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillReviewRequest.java new file mode 100644 index 00000000..1ca50751 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillReviewRequest.java @@ -0,0 +1,12 @@ +package com.iflytek.skillhub.dto; + +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +public record SkillReviewRequest( + @NotNull @Min(1) @Max(5) Short score, + @NotBlank @Size(max = 2000) String reviewText +) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillReviewResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillReviewResponse.java new file mode 100644 index 00000000..9c4156b1 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillReviewResponse.java @@ -0,0 +1,19 @@ +package com.iflytek.skillhub.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; +import java.time.Instant; + +@JsonInclude(JsonInclude.Include.NON_NULL) +public record SkillReviewResponse( + Long id, + String userId, + String displayName, + String avatarUrl, + short score, + String reviewText, + String status, + boolean authoredByViewer, + String moderationReason, + Instant createdAt, + Instant updatedAt +) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/exception/GlobalExceptionHandler.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/exception/GlobalExceptionHandler.java index e2b90929..60a31773 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/exception/GlobalExceptionHandler.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/exception/GlobalExceptionHandler.java @@ -17,6 +17,7 @@ import org.springframework.http.HttpMethod; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.orm.ObjectOptimisticLockingFailureException; import org.springframework.security.access.AccessDeniedException; import org.springframework.security.core.Authentication; import org.springframework.validation.FieldError; @@ -68,6 +69,15 @@ public class GlobalExceptionHandler { return renderLocalizedError(ex, HttpStatus.valueOf(ex.statusCode()), request); } + @ExceptionHandler(ObjectOptimisticLockingFailureException.class) + public ResponseEntity> handlePersistenceConflict( + RuntimeException ex, + HttpServletRequest request) { + logHandledException(HttpStatus.CONFLICT, "error.request.conflict", request); + return ResponseEntity.status(HttpStatus.CONFLICT).body( + apiResponseFactory.error(409, "error.request.conflict")); + } + @ExceptionHandler(MethodArgumentNotValidException.class) public ResponseEntity> handleValidation(MethodArgumentNotValidException ex, HttpServletRequest request) { String msg = ex.getBindingResult().getFieldErrors().stream() @@ -177,12 +187,6 @@ public class GlobalExceptionHandler { @ExceptionHandler(AsyncRequestTimeoutException.class) public ResponseEntity handleAsyncRequestTimeout(AsyncRequestTimeoutException ex, HttpServletRequest request) { - String path = request.getRequestURI(); - if (path != null && path.endsWith("/sse")) { - logger.debug("SSE timeout [requestId={}, path={}]", requestIdAccessor.current(), path); - return ResponseEntity.noContent().build(); - } - logHandledException(HttpStatus.REQUEST_TIMEOUT, "error.request.timeout", request); return ResponseEntity.status(HttpStatus.REQUEST_TIMEOUT).body( apiResponseFactory.error(408, "error.request.timeout")); diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/filter/RequestLoggingFilter.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/filter/RequestLoggingFilter.java index 1de3809b..551c7fb8 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/filter/RequestLoggingFilter.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/filter/RequestLoggingFilter.java @@ -8,8 +8,6 @@ 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; @@ -29,20 +27,11 @@ public class RequestLoggingFilter extends OncePerRequestFilter { private static final Set SKIP_PREFIXES = Set.of( "/actuator", "/favicon.ico", "/assets/" ); - private static final Set SKIP_SUFFIXES = Set.of( - "/sse" - ); - @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { String uri = request.getRequestURI(); - if (isNotificationSse(uri)) { - prepareSseResponse(response); - filterChain.doFilter(request, response); - return; - } if (shouldSkip(uri)) { filterChain.doFilter(request, response); return; @@ -91,24 +80,9 @@ 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 truncate(String value, int maxLength) { if (value == null || value.length() <= maxLength) { return value; diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/listener/NotificationEventListener.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/listener/NotificationEventListener.java index 6ba40f6e..7e86fb6a 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/listener/NotificationEventListener.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/listener/NotificationEventListener.java @@ -10,7 +10,7 @@ import com.iflytek.skillhub.domain.skill.SkillVersionRepository; import com.iflytek.skillhub.domain.social.SkillSubscriptionService; import com.iflytek.skillhub.domain.social.SubscriptionRecipientEligibility; import com.iflytek.skillhub.notification.domain.NotificationCategory; -import com.iflytek.skillhub.notification.service.NotificationDispatcher; +import com.iflytek.skillhub.notification.service.NotificationService; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.scheduling.annotation.Async; @@ -31,7 +31,7 @@ public class NotificationEventListener { private final SkillVersionRepository skillVersionRepository; private final NamespaceRepository namespaceRepository; private final RecipientResolver recipientResolver; - private final NotificationDispatcher dispatcher; + private final NotificationService notificationService; private final SkillSubscriptionService skillSubscriptionService; private final ObjectMapper objectMapper; private final SubscriptionRecipientEligibility subscriptionEligibility; @@ -40,7 +40,7 @@ public class NotificationEventListener { SkillVersionRepository skillVersionRepository, NamespaceRepository namespaceRepository, RecipientResolver recipientResolver, - NotificationDispatcher dispatcher, + NotificationService notificationService, SkillSubscriptionService skillSubscriptionService, ObjectMapper objectMapper, SubscriptionRecipientEligibility subscriptionEligibility) { @@ -48,7 +48,7 @@ public class NotificationEventListener { this.skillVersionRepository = skillVersionRepository; this.namespaceRepository = namespaceRepository; this.recipientResolver = recipientResolver; - this.dispatcher = dispatcher; + this.notificationService = notificationService; this.skillSubscriptionService = skillSubscriptionService; this.objectMapper = objectMapper; this.subscriptionEligibility = subscriptionEligibility; @@ -65,7 +65,7 @@ public class NotificationEventListener { Map body = bodyWithSkill(skill); versionLabel(event.versionId(), body); String json = toJson(body); - dispatcher.dispatch(event.publisherId(), NotificationCategory.PUBLISH, + notificationService.create(event.publisherId(), NotificationCategory.PUBLISH, "SKILL_PUBLISHED", title, json, "SKILL", event.skillId()); }); } @@ -88,7 +88,7 @@ public class NotificationEventListener { if (subscriberId.equals(event.publisherId())) { continue; // skip the publisher } - dispatcher.dispatch(subscriberId, NotificationCategory.PUBLISH, + notificationService.create(subscriberId, NotificationCategory.PUBLISH, "SUBSCRIPTION_NEW_VERSION", title, json, "SKILL", event.skillId()); } }); @@ -112,7 +112,7 @@ public class NotificationEventListener { if (subscriberId.equals(event.actorUserId())) { continue; // skip the actor } - dispatcher.dispatch(subscriberId, NotificationCategory.PUBLISH, + notificationService.create(subscriberId, NotificationCategory.PUBLISH, "SUBSCRIPTION_VERSION_YANKED", title, json, "SKILL", event.skillId()); } }); @@ -130,7 +130,7 @@ public class NotificationEventListener { String json = toJson(body); List admins = recipientResolver.resolveNamespaceAdmins(event.namespaceId()); for (String admin : admins.stream().distinct().toList()) { - dispatcher.dispatch(admin, NotificationCategory.REVIEW, + notificationService.create(admin, NotificationCategory.REVIEW, "REVIEW_SUBMITTED", title, json, "REVIEW", event.reviewId()); } }); @@ -147,7 +147,7 @@ public class NotificationEventListener { String json = toJson(body); List admins = recipientResolver.resolvePlatformUserAdmins(); for (String admin : admins.stream().distinct().toList()) { - dispatcher.dispatch(admin, NotificationCategory.REVIEW, + notificationService.create(admin, NotificationCategory.REVIEW, "PROFILE_REVIEW_SUBMITTED", title, json, "PROFILE_REVIEW", event.profileReviewId()); } } @@ -162,7 +162,7 @@ public class NotificationEventListener { body.put("reviewerId", event.reviewerId()); versionLabel(event.versionId(), body); String json = toJson(body); - dispatcher.dispatch(event.submitterId(), NotificationCategory.REVIEW, + notificationService.create(event.submitterId(), NotificationCategory.REVIEW, "REVIEW_APPROVED", title, json, "SKILL", event.skillId()); }); } @@ -178,7 +178,7 @@ public class NotificationEventListener { body.put("reason", event.reason()); versionLabel(event.versionId(), body); String json = toJson(body); - dispatcher.dispatch(event.submitterId(), NotificationCategory.REVIEW, + notificationService.create(event.submitterId(), NotificationCategory.REVIEW, "REVIEW_REJECTED", title, json, "SKILL", event.skillId()); }); } @@ -195,7 +195,7 @@ public class NotificationEventListener { String json = toJson(body); List admins = recipientResolver.resolvePlatformSkillAdmins(); for (String admin : admins.stream().distinct().toList()) { - dispatcher.dispatch(admin, NotificationCategory.PROMOTION, + notificationService.create(admin, NotificationCategory.PROMOTION, "PROMOTION_SUBMITTED", title, json, "PROMOTION", event.promotionId()); } }); @@ -210,7 +210,7 @@ public class NotificationEventListener { body.put("promotionId", event.promotionId()); body.put("reviewerId", event.reviewerId()); String json = toJson(body); - dispatcher.dispatch(event.submitterId(), NotificationCategory.PROMOTION, + notificationService.create(event.submitterId(), NotificationCategory.PROMOTION, "PROMOTION_APPROVED", title, json, "SKILL", event.skillId()); }); } @@ -225,7 +225,7 @@ public class NotificationEventListener { body.put("reviewerId", event.reviewerId()); body.put("reason", event.reason()); String json = toJson(body); - dispatcher.dispatch(event.submitterId(), NotificationCategory.PROMOTION, + notificationService.create(event.submitterId(), NotificationCategory.PROMOTION, "PROMOTION_REJECTED", title, json, "SKILL", event.skillId()); }); } @@ -241,7 +241,7 @@ public class NotificationEventListener { String json = toJson(body); List admins = recipientResolver.resolvePlatformSkillAdmins(); for (String admin : admins.stream().distinct().toList()) { - dispatcher.dispatch(admin, NotificationCategory.REPORT, + notificationService.create(admin, NotificationCategory.REPORT, "REPORT_SUBMITTED", title, json, "REPORT", event.reportId()); } }); @@ -257,7 +257,7 @@ public class NotificationEventListener { body.put("handlerId", event.handlerId()); body.put("action", event.action()); String json = toJson(body); - dispatcher.dispatch(event.reporterId(), NotificationCategory.REPORT, + notificationService.create(event.reporterId(), NotificationCategory.REPORT, "REPORT_RESOLVED", title, json, "SKILL", event.skillId()); }); } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/JpaGovernanceQueryRepository.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/JpaGovernanceQueryRepository.java index 646f032e..8b7edab7 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/JpaGovernanceQueryRepository.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/JpaGovernanceQueryRepository.java @@ -104,15 +104,19 @@ public class JpaGovernanceQueryRepository implements GovernanceQueryRepository { ? Map.of() : skillVersionRepository.findByIdIn(versionIds).stream() .collect(Collectors.toMap(SkillVersion::getId, Function.identity())); - List skillIds = distinct(versionsById.values().stream().map(SkillVersion::getSkillId).toList()); + Set skillIds = new LinkedHashSet<>(distinct( + versionsById.values().stream().map(SkillVersion::getSkillId).toList())); + skillIds.addAll(distinct(tasks.stream().map(ReviewTask::getSkillId).toList())); Map skillsById = skillIds.isEmpty() ? Map.of() - : skillRepository.findByIdIn(skillIds).stream() + : skillRepository.findByIdIn(List.copyOf(skillIds)).stream() .collect(Collectors.toMap(Skill::getId, Function.identity())); - List namespaceIds = distinct(skillsById.values().stream().map(Skill::getNamespaceId).toList()); + Set namespaceIds = new LinkedHashSet<>(distinct( + skillsById.values().stream().map(Skill::getNamespaceId).toList())); + namespaceIds.addAll(distinct(tasks.stream().map(ReviewTask::getNamespaceId).toList())); Map namespacesById = namespaceIds.isEmpty() ? Map.of() - : namespaceRepository.findByIdIn(namespaceIds).stream() + : namespaceRepository.findByIdIn(List.copyOf(namespaceIds)).stream() .collect(Collectors.toMap(Namespace::getId, Function.identity())); List userIds = distinctStrings(tasks.stream() .flatMap(task -> java.util.stream.Stream.of(task.getSubmittedBy(), task.getReviewedBy())) @@ -168,9 +172,14 @@ public class JpaGovernanceQueryRepository implements GovernanceQueryRepository { } private ReviewTaskResponse toReviewTaskResponse(ReviewTask task, ReviewReadBundle bundle) { - SkillVersion version = require(bundle.versionsById(), task.getSkillVersionId(), "skill_version.not_found"); - Skill skill = require(bundle.skillsById(), version.getSkillId(), "skill.not_found"); - Namespace namespace = require(bundle.namespacesById(), skill.getNamespaceId(), "namespace.not_found"); + Long skillId = task.getSkillId() != null + ? task.getSkillId() + : require(bundle.versionsById(), task.getSkillVersionId(), "skill_version.not_found").getSkillId(); + Skill skill = require(bundle.skillsById(), skillId, "skill.not_found"); + Namespace namespace = require(bundle.namespacesById(), task.getNamespaceId(), "namespace.not_found"); + String skillVersion = task.getSkillVersion() != null + ? task.getSkillVersion() + : require(bundle.versionsById(), task.getSkillVersionId(), "skill_version.not_found").getVersion(); UserAccount submittedBy = bundle.usersById().get(task.getSubmittedBy()); UserAccount reviewedBy = task.getReviewedBy() != null ? bundle.usersById().get(task.getReviewedBy()) : null; return new ReviewTaskResponse( @@ -178,7 +187,7 @@ public class JpaGovernanceQueryRepository implements GovernanceQueryRepository { task.getSkillVersionId(), namespace.getSlug(), skill.getSlug(), - version.getVersion(), + skillVersion, task.getStatus().name(), task.getSubmittedBy(), submittedBy != null ? submittedBy.getDisplayName() : null, diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/JpaReviewProgressQueryRepository.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/JpaReviewProgressQueryRepository.java new file mode 100644 index 00000000..04c09be0 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/JpaReviewProgressQueryRepository.java @@ -0,0 +1,176 @@ +package com.iflytek.skillhub.repository; + +import com.iflytek.skillhub.domain.review.ReviewTaskStatus; +import com.iflytek.skillhub.dto.ReviewProgressPageResponse; +import com.iflytek.skillhub.dto.ReviewProgressResponse; +import com.iflytek.skillhub.dto.ReviewProgressStatusCounts; +import jakarta.persistence.EntityManager; +import jakarta.persistence.Query; +import java.sql.Timestamp; +import java.time.Instant; +import java.time.OffsetDateTime; +import java.util.List; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; + +/** + * PostgreSQL read-model query for review progress. + * + *

Direct SQL is intentional here: the page boundary applies to grouped skill-version attempts, + * not individual review tasks. Window functions keep grouping, latest-attempt selection, counts, + * filtering, and pagination in the database instead of loading an author's full history.

+ */ +@Repository +public class JpaReviewProgressQueryRepository implements ReviewProgressQueryRepository { + + private static final String RANKED_CTE = """ + WITH ranked AS ( + SELECT task.id, + task.skill_id, + task.namespace_id, + task.skill_version, + task.status, + task.review_comment, + task.submitted_at, + task.reviewed_at, + ROW_NUMBER() OVER ( + PARTITION BY task.skill_id, task.skill_version + ORDER BY task.submitted_at DESC, task.id DESC + ) AS attempt_rank, + COUNT(*) OVER ( + PARTITION BY task.skill_id, task.skill_version + ) AS attempt_count + FROM review_task task + WHERE task.submitted_by = :userId + ), latest AS ( + SELECT * + FROM ranked + WHERE attempt_rank = 1 + ) + """; + + private static final String MY_PROGRESS_SQL = RANKED_CTE + """ + SELECT latest.id, + latest.skill_id, + namespace.slug, + skill.slug, + latest.skill_version, + latest.status, + latest.review_comment, + latest.submitted_at, + latest.reviewed_at, + latest.attempt_count + FROM latest + JOIN skill ON skill.id = latest.skill_id + JOIN namespace ON namespace.id = latest.namespace_id + WHERE ( + :query = '' + OR LOWER(skill.slug) LIKE :queryPattern + OR LOWER(namespace.slug) LIKE :queryPattern + ) + AND (:status = '' OR latest.status = :status) + ORDER BY latest.submitted_at DESC, latest.id DESC + OFFSET :offset ROWS FETCH NEXT :size ROWS ONLY + """; + + private static final String MY_PROGRESS_SUMMARY_SQL = RANKED_CTE + """ + SELECT COUNT(*) FILTER (WHERE :status = '' OR latest.status = :status) AS filtered_total, + COUNT(*) FILTER (WHERE latest.status = 'PENDING') AS pending_count, + COUNT(*) FILTER (WHERE latest.status = 'APPROVED') AS approved_count, + COUNT(*) FILTER (WHERE latest.status = 'REJECTED') AS rejected_count + FROM latest + JOIN skill ON skill.id = latest.skill_id + JOIN namespace ON namespace.id = latest.namespace_id + WHERE :query = '' + OR LOWER(skill.slug) LIKE :queryPattern + OR LOWER(namespace.slug) LIKE :queryPattern + """; + + private final EntityManager entityManager; + + public JpaReviewProgressQueryRepository(EntityManager entityManager) { + this.entityManager = entityManager; + } + + @Override + @Transactional(readOnly = true) + public ReviewProgressPageResponse findMyProgress( + String userId, + ReviewTaskStatus status, + String query, + int page, + int size) { + String normalizedQuery = query == null ? "" : query.trim().toLowerCase(java.util.Locale.ROOT); + String statusName = status != null ? status.name() : ""; + String queryPattern = "%" + normalizedQuery + "%"; + Query nativeQuery = bindFilters( + entityManager.createNativeQuery(MY_PROGRESS_SQL), + userId, + statusName, + normalizedQuery, + queryPattern) + .setParameter("offset", (long) page * size) + .setParameter("size", size); + Query summaryQuery = bindFilters( + entityManager.createNativeQuery(MY_PROGRESS_SUMMARY_SQL), + userId, + statusName, + normalizedQuery, + queryPattern); + + @SuppressWarnings("unchecked") + List rows = nativeQuery.getResultList(); + List items = rows.stream().map(this::mapRow).toList(); + Object[] summary = (Object[]) summaryQuery.getSingleResult(); + long total = number(summary[0]).longValue(); + ReviewProgressStatusCounts statusCounts = new ReviewProgressStatusCounts( + number(summary[1]).longValue(), + number(summary[2]).longValue(), + number(summary[3]).longValue() + ); + return new ReviewProgressPageResponse(items, total, page, size, statusCounts); + } + + private Query bindFilters( + Query query, + String userId, + String status, + String normalizedQuery, + String queryPattern) { + return query + .setParameter("userId", userId) + .setParameter("status", status) + .setParameter("query", normalizedQuery) + .setParameter("queryPattern", queryPattern); + } + + private ReviewProgressResponse mapRow(Object[] row) { + return new ReviewProgressResponse( + number(row[0]).longValue(), + number(row[1]).longValue(), + (String) row[2], + (String) row[3], + (String) row[4], + String.valueOf(row[5]), + (String) row[6], + instant(row[7]), + instant(row[8]), + number(row[9]).longValue() + ); + } + + private Number number(Object value) { + if (value instanceof Number number) { + return number; + } + throw new IllegalStateException("Expected numeric review progress value, got " + value); + } + + private Instant instant(Object value) { + if (value == null) return null; + if (value instanceof Instant instant) return instant; + if (value instanceof OffsetDateTime offsetDateTime) return offsetDateTime.toInstant(); + if (value instanceof Timestamp timestamp) return timestamp.toInstant(); + throw new IllegalStateException("Expected review progress timestamp, got " + value.getClass().getName()); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/JpaSkillReviewQueryRepository.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/JpaSkillReviewQueryRepository.java new file mode 100644 index 00000000..886483a5 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/JpaSkillReviewQueryRepository.java @@ -0,0 +1,68 @@ +package com.iflytek.skillhub.repository; + +import com.iflytek.skillhub.domain.social.SkillRating; +import com.iflytek.skillhub.domain.social.SkillRatingRepository; +import com.iflytek.skillhub.domain.user.UserAccount; +import com.iflytek.skillhub.domain.user.UserAccountRepository; +import com.iflytek.skillhub.dto.SkillReviewResponse; +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Repository; + +@Repository +public class JpaSkillReviewQueryRepository implements SkillReviewQueryRepository { + + private final SkillRatingRepository ratingRepository; + private final UserAccountRepository userAccountRepository; + + public JpaSkillReviewQueryRepository(SkillRatingRepository ratingRepository, + UserAccountRepository userAccountRepository) { + this.ratingRepository = ratingRepository; + this.userAccountRepository = userAccountRepository; + } + + @Override + public Page list(Long skillId, + String viewerId, + boolean includeHidden, + Pageable pageable) { + Page reviews = includeHidden + ? ratingRepository.findReviewsBySkillId(skillId, pageable) + : ratingRepository.findVisibleReviewsBySkillId(skillId, pageable); + List authorIds = reviews.getContent().stream() + .map(SkillRating::getUserId) + .distinct() + .toList(); + Map authors = userAccountRepository.findByIdIn(authorIds).stream() + .collect(Collectors.toMap(UserAccount::getId, Function.identity())); + return reviews.map(review -> toResponse( + review, + authors.get(review.getUserId()), + viewerId, + includeHidden + )); + } + + private SkillReviewResponse toResponse(SkillRating review, + UserAccount author, + String viewerId, + boolean includeModerationDetails) { + return new SkillReviewResponse( + review.getId(), + includeModerationDetails ? review.getUserId() : null, + author != null ? author.getDisplayName() : review.getUserId(), + author != null ? author.getAvatarUrl() : null, + review.getScore(), + review.getReviewText(), + review.getReviewStatus().name(), + review.getUserId().equals(viewerId), + includeModerationDetails ? review.getModerationReason() : null, + review.getCreatedAt(), + review.getUpdatedAt() + ); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/ReviewProgressQueryRepository.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/ReviewProgressQueryRepository.java new file mode 100644 index 00000000..535cefbe --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/ReviewProgressQueryRepository.java @@ -0,0 +1,18 @@ +package com.iflytek.skillhub.repository; + +import com.iflytek.skillhub.domain.review.ReviewTaskStatus; +import com.iflytek.skillhub.dto.ReviewProgressPageResponse; + +/** + * Query seam for author-facing review progress grouped by skill version. + */ +public interface ReviewProgressQueryRepository { + + ReviewProgressPageResponse findMyProgress( + String userId, + ReviewTaskStatus status, + String query, + int page, + int size + ); +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/SkillReviewQueryRepository.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/SkillReviewQueryRepository.java new file mode 100644 index 00000000..8a22206c --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/SkillReviewQueryRepository.java @@ -0,0 +1,9 @@ +package com.iflytek.skillhub.repository; + +import com.iflytek.skillhub.dto.SkillReviewResponse; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +public interface SkillReviewQueryRepository { + Page list(Long skillId, String viewerId, boolean includeHidden, Pageable pageable); +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/GovernanceWorkflowAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/GovernanceWorkflowAppService.java index 9aac59d9..ae578ae8 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/GovernanceWorkflowAppService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/GovernanceWorkflowAppService.java @@ -8,10 +8,12 @@ import com.iflytek.skillhub.dto.NamespaceResponse; import com.iflytek.skillhub.dto.PageResponse; import com.iflytek.skillhub.dto.PromotionResponseDto; import com.iflytek.skillhub.dto.ReviewSkillDetailResponse; +import com.iflytek.skillhub.dto.ReviewProgressPageResponse; import com.iflytek.skillhub.dto.ReviewTaskResponse; import com.iflytek.skillhub.dto.SkillLifecycleMutationResponse; import com.iflytek.skillhub.dto.SkillVersionRereleaseRequest; import java.io.InputStream; +import java.util.List; import java.util.Map; import org.springframework.stereotype.Service; @@ -93,6 +95,26 @@ public class GovernanceWorkflowAppService { return reviewPortalAppService.listMySubmissions(page, size, userId); } + public ReviewProgressPageResponse listMyReviewProgress( + String status, + String query, + int page, + int size, + String userId) { + return reviewPortalAppService.listMyProgress(status, query, page, size, userId); + } + + public List listMyReviewAttempts(Long reviewTaskId, String userId) { + return reviewPortalAppService.listMyAttempts(reviewTaskId, userId); + } + + public List listReviewAttempts( + Long reviewTaskId, + String userId, + Map userNsRoles) { + return reviewPortalAppService.listReviewAttempts(reviewTaskId, userId, userNsRoles); + } + public ReviewTaskResponse getReviewDetail(Long reviewTaskId, String userId, Map userNsRoles) { diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/ReviewPortalAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/ReviewPortalAppService.java index f944f2d5..6d68193f 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/ReviewPortalAppService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/ReviewPortalAppService.java @@ -13,9 +13,11 @@ import com.iflytek.skillhub.domain.review.ReviewTaskStatus; import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException; import com.iflytek.skillhub.dto.PageResponse; +import com.iflytek.skillhub.dto.ReviewProgressPageResponse; import com.iflytek.skillhub.dto.ReviewTaskResponse; import com.iflytek.skillhub.observability.RequestIdAccessor; import com.iflytek.skillhub.repository.GovernanceQueryRepository; +import com.iflytek.skillhub.repository.ReviewProgressQueryRepository; import java.util.List; import java.util.Map; import java.util.Set; @@ -34,6 +36,7 @@ public class ReviewPortalAppService { private final ReviewTaskRepository reviewTaskRepository; private final NamespaceRepository namespaceRepository; private final GovernanceQueryRepository governanceQueryRepository; + private final ReviewProgressQueryRepository reviewProgressQueryRepository; private final RbacService rbacService; private final AuditLogService auditLogService; private final RequestIdAccessor requestIdAccessor; @@ -42,6 +45,7 @@ public class ReviewPortalAppService { ReviewTaskRepository reviewTaskRepository, NamespaceRepository namespaceRepository, GovernanceQueryRepository governanceQueryRepository, + ReviewProgressQueryRepository reviewProgressQueryRepository, RbacService rbacService, AuditLogService auditLogService, RequestIdAccessor requestIdAccessor) { @@ -49,6 +53,7 @@ public class ReviewPortalAppService { this.reviewTaskRepository = reviewTaskRepository; this.namespaceRepository = namespaceRepository; this.governanceQueryRepository = governanceQueryRepository; + this.reviewProgressQueryRepository = reviewProgressQueryRepository; this.rbacService = rbacService; this.auditLogService = auditLogService; this.requestIdAccessor = requestIdAccessor; @@ -212,6 +217,63 @@ public class ReviewPortalAppService { )); } + public ReviewProgressPageResponse listMyProgress( + String status, + String query, + int page, + int size, + String userId) { + ReviewTaskStatus reviewStatus = status == null || status.isBlank() + ? null + : ReviewTaskStatus.valueOf(status.toUpperCase(java.util.Locale.ROOT)); + int safePage = Math.max(page, 0); + int safeSize = Math.min(Math.max(size, 1), 100); + return reviewProgressQueryRepository.findMyProgress( + userId, + reviewStatus, + query != null ? query : "", + safePage, + safeSize + ); + } + + public List listMyAttempts(Long reviewTaskId, String userId) { + ReviewTask anchor = reviewTaskRepository.findById(reviewTaskId) + .orElseThrow(() -> new DomainNotFoundException("review_task.not_found", reviewTaskId)); + if (!anchor.getSubmittedBy().equals(userId)) { + throw new DomainForbiddenException("review.no_permission"); + } + + List attempts = reviewTaskRepository + .findBySubmittedByAndSkillIdAndSkillVersionOrderBySubmittedAtDescIdDesc( + userId, anchor.getSkillId(), anchor.getSkillVersion()); + return governanceQueryRepository.getReviewTaskResponses(attempts); + } + + public List listReviewAttempts( + Long reviewTaskId, + String userId, + Map userNsRoles) { + ReviewTask anchor = reviewTaskRepository.findById(reviewTaskId) + .orElseThrow(() -> new DomainNotFoundException("review_task.not_found", reviewTaskId)); + Namespace namespace = namespaceRepository.findById(anchor.getNamespaceId()) + .orElseThrow(() -> new DomainNotFoundException( + "namespace.not_found", anchor.getNamespaceId())); + if (!reviewService.canReviewNamespace( + anchor, + userId, + namespace.getType(), + normalizeRoles(userNsRoles), + platformRoles(userId))) { + throw new DomainForbiddenException("review.no_permission"); + } + + List attempts = reviewTaskRepository + .findBySkillIdAndSkillVersionOrderBySubmittedAtDescIdDesc( + anchor.getSkillId(), anchor.getSkillVersion()); + return governanceQueryRepository.getReviewTaskResponses(attempts); + } + public ReviewTaskResponse getReviewDetail(Long reviewTaskId, String userId, Map userNsRoles) { diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SecurityScanRetryAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SecurityScanRetryAppService.java new file mode 100644 index 00000000..19667b7e --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SecurityScanRetryAppService.java @@ -0,0 +1,131 @@ +package com.iflytek.skillhub.service; + +import com.iflytek.skillhub.domain.audit.AuditDetail; +import com.iflytek.skillhub.domain.audit.AuditLogService; +import com.iflytek.skillhub.domain.namespace.NamespaceRole; +import com.iflytek.skillhub.domain.security.ScanTask; +import com.iflytek.skillhub.domain.security.ScannerType; +import com.iflytek.skillhub.domain.security.SecurityAuditRepository; +import com.iflytek.skillhub.domain.security.SecurityScanService; +import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; +import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; +import com.iflytek.skillhub.domain.skill.Skill; +import com.iflytek.skillhub.domain.skill.SkillRepository; +import com.iflytek.skillhub.domain.skill.SkillVersion; +import com.iflytek.skillhub.domain.skill.SkillVersionRepository; +import com.iflytek.skillhub.domain.skill.SkillVersionStatus; +import com.iflytek.skillhub.dto.SkillLifecycleMutationResponse; +import com.iflytek.skillhub.storage.ObjectStorageService; +import java.util.Map; +import java.util.Set; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class SecurityScanRetryAppService { + + private final SkillRepository skillRepository; + private final SkillVersionRepository skillVersionRepository; + private final SecurityAuditRepository securityAuditRepository; + private final SecurityScanService securityScanService; + private final ObjectStorageService objectStorageService; + private final AuditLogService auditLogService; + + public SecurityScanRetryAppService(SkillRepository skillRepository, + SkillVersionRepository skillVersionRepository, + SecurityAuditRepository securityAuditRepository, + SecurityScanService securityScanService, + ObjectStorageService objectStorageService, + AuditLogService auditLogService) { + this.skillRepository = skillRepository; + this.skillVersionRepository = skillVersionRepository; + this.securityAuditRepository = securityAuditRepository; + this.securityScanService = securityScanService; + this.objectStorageService = objectStorageService; + this.auditLogService = auditLogService; + } + + @Transactional + public SkillLifecycleMutationResponse retry(Long skillId, + Long versionId, + String userId, + Set platformRoles, + Map namespaceRoles, + AuditRequestContext auditContext) { + Skill skill = skillRepository.findById(skillId) + .orElseThrow(() -> new DomainBadRequestException("error.skill.notFound", skillId)); + authorize(skill, userId, platformRoles, namespaceRoles); + + SkillVersionStatus observedStatus = skillVersionRepository.findStatusByIdAndSkillId(versionId, skillId) + .orElseThrow(() -> new DomainBadRequestException("error.skill.version.notFound", versionId)); + if (observedStatus != SkillVersionStatus.SCAN_FAILED + && observedStatus != SkillVersionStatus.SCANNING) { + throw new DomainBadRequestException("error.security.scan.retry.status", observedStatus); + } + if (!securityScanService.isEnabled()) { + throw new DomainBadRequestException("error.security.scan.retry.disabled"); + } + + String bundleKey = bundleKey(skillId, versionId); + if (observedStatus == SkillVersionStatus.SCAN_FAILED + && !objectStorageService.exists(bundleKey)) { + throw new DomainBadRequestException("error.security.scan.retry.bundleMissing"); + } + + SkillVersion version = skillVersionRepository.findByIdForUpdate(versionId) + .filter(candidate -> candidate.getSkillId().equals(skillId)) + .orElseThrow(() -> new DomainBadRequestException("error.skill.version.notFound", versionId)); + + if (version.getStatus() == SkillVersionStatus.SCANNING && hasActiveAttempt(versionId)) { + return response(skillId, versionId); + } + if (version.getStatus() != SkillVersionStatus.SCAN_FAILED) { + throw new DomainBadRequestException("error.security.scan.retry.status", version.getStatus()); + } + + ScanTask task = securityScanService.retryStoredBundleScan(version, bundleKey, userId); + auditLogService.record( + userId, + "RETRY_SECURITY_SCAN", + "SKILL_VERSION", + versionId, + null, + auditContext.clientIp(), + auditContext.userAgent(), + AuditDetail.of("taskId", task.taskId(), "version", version.getVersion()) + ); + return response(skillId, versionId); + } + + private void authorize(Skill skill, + String userId, + Set platformRoles, + Map namespaceRoles) { + Set roles = platformRoles != null ? platformRoles : Set.of(); + Map memberships = namespaceRoles != null ? namespaceRoles : Map.of(); + NamespaceRole namespaceRole = memberships.get(skill.getNamespaceId()); + boolean allowed = skill.getOwnerId().equals(userId) + || namespaceRole == NamespaceRole.OWNER + || namespaceRole == NamespaceRole.ADMIN + || roles.contains("SUPER_ADMIN") + || roles.contains("SKILL_ADMIN"); + if (!allowed) { + throw new DomainForbiddenException("error.forbidden"); + } + } + + private boolean hasActiveAttempt(Long versionId) { + return securityAuditRepository + .findLatestActiveByVersionIdAndScannerType(versionId, ScannerType.SKILL_SCANNER) + .filter(audit -> audit.getScannedAt() == null) + .isPresent(); + } + + private String bundleKey(Long skillId, Long versionId) { + return String.format("packages/%d/%d/bundle.zip", skillId, versionId); + } + + private SkillLifecycleMutationResponse response(Long skillId, Long versionId) { + return new SkillLifecycleMutationResponse(skillId, versionId, "RETRY_SECURITY_SCAN", "SCANNING"); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillReviewAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillReviewAppService.java new file mode 100644 index 00000000..49fba830 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillReviewAppService.java @@ -0,0 +1,206 @@ +package com.iflytek.skillhub.service; + +import com.iflytek.skillhub.domain.audit.AuditDetail; +import com.iflytek.skillhub.domain.audit.AuditLogService; +import com.iflytek.skillhub.domain.namespace.NamespaceRole; +import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; +import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; +import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException; +import com.iflytek.skillhub.domain.skill.Skill; +import com.iflytek.skillhub.domain.skill.SkillRepository; +import com.iflytek.skillhub.domain.skill.SkillStatus; +import com.iflytek.skillhub.domain.skill.SkillVersionRepository; +import com.iflytek.skillhub.domain.skill.SkillVersionStatus; +import com.iflytek.skillhub.domain.skill.VisibilityChecker; +import com.iflytek.skillhub.domain.social.SkillRating; +import com.iflytek.skillhub.domain.social.SkillRatingService; +import com.iflytek.skillhub.dto.PageResponse; +import com.iflytek.skillhub.dto.SkillReviewMeResponse; +import com.iflytek.skillhub.dto.SkillReviewResponse; +import com.iflytek.skillhub.observability.RequestIdAccessor; +import com.iflytek.skillhub.repository.SkillReviewQueryRepository; +import java.util.Map; +import java.util.Set; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class SkillReviewAppService { + + private static final int MAX_PAGE_SIZE = 100; + + private final SkillRepository skillRepository; + private final SkillVersionRepository skillVersionRepository; + private final VisibilityChecker visibilityChecker; + private final SkillRatingService ratingService; + private final SkillReviewQueryRepository queryRepository; + private final AuditLogService auditLogService; + private final RequestIdAccessor requestIdAccessor; + + public SkillReviewAppService(SkillRepository skillRepository, + SkillVersionRepository skillVersionRepository, + VisibilityChecker visibilityChecker, + SkillRatingService ratingService, + SkillReviewQueryRepository queryRepository, + AuditLogService auditLogService, + RequestIdAccessor requestIdAccessor) { + this.skillRepository = skillRepository; + this.skillVersionRepository = skillVersionRepository; + this.visibilityChecker = visibilityChecker; + this.ratingService = ratingService; + this.queryRepository = queryRepository; + this.auditLogService = auditLogService; + this.requestIdAccessor = requestIdAccessor; + } + + public PageResponse list(Long skillId, + String viewerId, + Map namespaceRoles, + Set platformRoles, + int page, + int size) { + requireVisibleSkill(skillId, viewerId, namespaceRoles, platformRoles); + if (page < 0 || size < 1 || size > MAX_PAGE_SIZE) { + throw new DomainBadRequestException("error.pagination.invalid", MAX_PAGE_SIZE); + } + boolean includeHidden = isReviewModerator(platformRoles); + return PageResponse.from(queryRepository.list( + skillId, + viewerId, + includeHidden, + PageRequest.of(page, size) + )); + } + + public SkillReviewMeResponse getMine(Long skillId, + String userId, + Map namespaceRoles, + Set platformRoles) { + return ratingService.getUserFeedback(skillId, userId) + .map(this::toMine) + .orElseGet(SkillReviewMeResponse::empty); + } + + public SkillReviewMeResponse upsert(Long skillId, + String userId, + short score, + String reviewText, + Map namespaceRoles, + Set platformRoles) { + requireInteractableSkill(skillId, userId, namespaceRoles, platformRoles); + return toMine(ratingService.upsertReview(skillId, userId, score, reviewText)); + } + + public SkillReviewMeResponse clear(Long skillId, + String userId, + Map namespaceRoles, + Set platformRoles) { + return toMine(ratingService.clearReview(skillId, userId)); + } + + @Transactional + public SkillReviewResponse hide(Long reviewId, + String moderatorId, + String reason, + AuditRequestContext auditContext) { + SkillRating review = ratingService.hideReview(reviewId, moderatorId, reason); + recordModerationAudit("SKILL_REVIEW_HIDE", review, moderatorId, reason, auditContext); + return toModerationResponse(review); + } + + @Transactional + public SkillReviewResponse restore(Long reviewId, + String moderatorId, + AuditRequestContext auditContext) { + SkillRating review = ratingService.restoreReview(reviewId, moderatorId); + recordModerationAudit("SKILL_REVIEW_RESTORE", review, moderatorId, null, auditContext); + return toModerationResponse(review); + } + + private Skill requireVisibleSkill(Long skillId, + String userId, + Map namespaceRoles, + Set platformRoles) { + Skill skill = skillRepository.findById(skillId) + .orElseThrow(() -> new DomainNotFoundException("skill.not_found", skillId)); + if (!visibilityChecker.canAccess( + skill, + userId, + namespaceRoles != null ? namespaceRoles : Map.of(), + platformRoles != null ? platformRoles : Set.of())) { + throw new DomainForbiddenException("error.skill.access.denied", skill.getSlug()); + } + return skill; + } + + private Skill requireInteractableSkill(Long skillId, + String userId, + Map namespaceRoles, + Set platformRoles) { + Skill skill = requireVisibleSkill(skillId, userId, namespaceRoles, platformRoles); + boolean published = skill.getLatestVersionId() != null + && skillVersionRepository.findById(skill.getLatestVersionId()) + .map(version -> version.getStatus() == SkillVersionStatus.PUBLISHED) + .orElse(false); + if (skill.getStatus() != SkillStatus.ACTIVE || !published) { + throw new DomainBadRequestException("error.skillReview.notInteractable"); + } + return skill; + } + + private SkillReviewMeResponse toMine(SkillRating review) { + return new SkillReviewMeResponse( + true, + review.getScore(), + review.hasReview(), + review.hasReview() ? review.getId() : null, + review.getReviewText(), + review.hasReview() ? review.getReviewStatus().name() : null, + review.getModerationReason(), + review.getCreatedAt(), + review.getUpdatedAt() + ); + } + + private SkillReviewResponse toModerationResponse(SkillRating review) { + return new SkillReviewResponse( + review.getId(), + review.getUserId(), + review.getUserId(), + null, + review.getScore(), + review.getReviewText(), + review.getReviewStatus().name(), + false, + review.getModerationReason(), + review.getCreatedAt(), + review.getUpdatedAt() + ); + } + + private boolean isReviewModerator(Set platformRoles) { + return platformRoles != null + && (platformRoles.contains("SKILL_ADMIN") || platformRoles.contains("SUPER_ADMIN")); + } + + private void recordModerationAudit(String action, + SkillRating review, + String moderatorId, + String reason, + AuditRequestContext context) { + auditLogService.record( + moderatorId, + action, + "SKILL_REVIEW", + review.getId(), + requestIdAccessor.current(), + context != null ? context.clientIp() : null, + context != null ? context.userAgent() : null, + AuditDetail.builder() + .put("skillId", review.getSkillId()) + .put("reason", reason) + .build() + ); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/stream/AbstractStreamConsumer.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/stream/AbstractStreamConsumer.java index 67ab8fef..a91fdb0f 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/stream/AbstractStreamConsumer.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/stream/AbstractStreamConsumer.java @@ -28,8 +28,8 @@ public abstract class AbstractStreamConsumer { protected final Logger log = LoggerFactory.getLogger(getClass()); private static final String FIELD_RETRY_COUNT = "retryCount"; - private static final int MAX_RETRY_COUNT = 3; - private static final int READ_BATCH_SIZE = 10; + private static final int DEFAULT_MAX_RETRY_COUNT = 3; + private static final int DEFAULT_READ_BATCH_SIZE = 10; private static final Duration POLL_TIMEOUT = Duration.ofSeconds(2); private final RedissonClient redissonClient; @@ -170,7 +170,7 @@ public abstract class AbstractStreamConsumer { groupName, consumerName, StreamReadGroupArgs.neverDelivered() - .count(READ_BATCH_SIZE) + .count(readBatchSize()) .timeout(POLL_TIMEOUT) ); processMessages(messages); @@ -237,21 +237,27 @@ public abstract class AbstractStreamConsumer { acknowledge(messageId); } catch (Exception e) { messageObservationSupport.recordCurrentError(e); - handleFailure(payload, retryCount, e); - acknowledge(messageId); + if (shouldDeferFailure(payload, e)) { + markDeferred(payload, e); + } else { + handleFailure(payload, retryCount, e); + acknowledge(messageId); + } } } private void handleFailure(T payload, int retryCount, Exception e) { - if (retryCount < MAX_RETRY_COUNT) { + if (shouldRetry(payload, e, retryCount)) { // Retry publication remains inside the current consumer scope, so the new producer // span and message carrier continue the original trace. retryMessage(payload, retryCount + 1); return; } - markFailed(payload, truncateError( - taskDisplayName() + " failed (retried " + retryCount + " times): " + e.getMessage() - )); + markFailed(payload, truncateError(finalFailureReason(payload, e, retryCount))); + } + + protected String finalFailureReason(T payload, Exception error, int retryCount) { + return taskDisplayName() + " failed (retried " + retryCount + " times): " + error.getMessage(); } protected int parseRetryCount(Map data) { @@ -274,7 +280,32 @@ public abstract class AbstractStreamConsumer { } protected void acknowledge(StreamMessageId messageId) { - stream().ack(groupName, messageId); + if (stream().ack(groupName, messageId) > 0) { + // This stream has one consumer group. Removing acknowledged entries prevents + // completed scan tasks from growing the Redis stream without bound. + stream().remove(messageId); + } + } + + protected int readBatchSize() { + return DEFAULT_READ_BATCH_SIZE; + } + + protected int maxRetryCount() { + return DEFAULT_MAX_RETRY_COUNT; + } + + protected boolean shouldRetry(T payload, Exception error, int retryCount) { + return retryCount < maxRetryCount(); + } + + protected boolean shouldDeferFailure(T payload, Exception error) { + return false; + } + + protected void markDeferred(T payload, Exception error) { + log.warn("Deferring {} task without acknowledging its stream entry: {}", + taskDisplayName(), payloadIdentifier(payload), error); } protected final RStream stream() { diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/stream/ScanTaskConsumer.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/stream/ScanTaskConsumer.java index 3b1b2c66..490ce90b 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/stream/ScanTaskConsumer.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/stream/ScanTaskConsumer.java @@ -11,6 +11,7 @@ import com.iflytek.skillhub.domain.skill.SkillVersionRepository; import com.iflytek.skillhub.domain.skill.SkillVersionStatus; import com.iflytek.skillhub.observability.MessageObservationSupport; import com.iflytek.skillhub.storage.ObjectStorageService; +import com.iflytek.skillhub.infra.scanner.SecurityScanException; import org.redisson.api.RLock; import org.redisson.api.RedissonClient; @@ -20,12 +21,18 @@ import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.nio.file.StandardCopyOption; +import java.time.Clock; +import java.time.DateTimeException; import java.time.Duration; +import java.time.Instant; import java.util.Comparator; import java.util.Map; +import java.util.Objects; public class ScanTaskConsumer extends AbstractStreamConsumer { private static final Path SCAN_TEMP_DIR = Paths.get("/tmp/skillhub-scans").toAbsolutePath().normalize(); + private static final Duration DEFAULT_MAX_UNAVAILABLE_AGE = Duration.ofHours(1); + private static final Duration MAX_CLOCK_SKEW = Duration.ofMinutes(5); private final RedissonClient redissonClient; private final SecurityScanner securityScanner; @@ -33,6 +40,9 @@ public class ScanTaskConsumer extends AbstractStreamConsumer version.getStatus() == SkillVersionStatus.SCANNING) - .ifPresent(version -> { - version.setStatus(SkillVersionStatus.SCAN_FAILED); - skillVersionRepository.save(version); - }); + securityScanService.processScanFailure( + payload.taskId(), payload.versionId(), payload.scannerType(), error); } finally { cleanupTempPath(payload.cleanupPath()); } } + @Override protected void retryMessage(ScanTaskPayload payload, int retryCount) { log.warn("Retrying security scan task: taskId={}, versionId={}, scanner={}, nextRetryCount={}, source={}", @@ -287,6 +354,55 @@ public class ScanTaskConsumer extends AbstractStreamConsumer now.plus(MAX_CLOCK_SKEW).toEpochMilli()) { + return true; + } + try { + return !Instant.ofEpochMilli(createdAtMillis).plus(maxUnavailableAge).isAfter(now); + } catch (DateTimeException | ArithmeticException ignored) { + return true; + } + } + + private Duration taskAge(ScanTaskPayload payload) { + try { + Duration age = Duration.between(Instant.ofEpochMilli(payload.createdAtMillis()), clock.instant()); + return age.isNegative() ? Duration.ZERO : age; + } catch (DateTimeException | ArithmeticException ignored) { + return maxUnavailableAge; + } + } + + private long parseCreatedAtMillis(String messageId, String value) { + Long createdAt = parsePositiveLong(value); + if (createdAt != null) { + return createdAt; + } + int separator = messageId.indexOf('-'); + String redisTimestamp = separator >= 0 ? messageId.substring(0, separator) : messageId; + Long fallback = parsePositiveLong(redisTimestamp); + return fallback != null ? fallback : 0L; + } + + private Long parsePositiveLong(String value) { + if (value == null || value.isBlank()) { + return null; + } + try { + long parsed = Long.parseLong(value); + return parsed > 0 ? parsed : null; + } catch (NumberFormatException ignored) { + return null; + } + } + protected static final class ScanTaskPayload { private final String taskId; private final Long versionId; @@ -294,11 +410,12 @@ public class ScanTaskConsumer extends AbstractStreamConsumer ''; diff --git a/server/skillhub-app/src/main/resources/db/migration/V48__security_audit_failure_reason.sql b/server/skillhub-app/src/main/resources/db/migration/V48__security_audit_failure_reason.sql new file mode 100644 index 00000000..acc2bbbf --- /dev/null +++ b/server/skillhub-app/src/main/resources/db/migration/V48__security_audit_failure_reason.sql @@ -0,0 +1,2 @@ +ALTER TABLE security_audit + ADD COLUMN failure_reason VARCHAR(1000); diff --git a/server/skillhub-app/src/main/resources/messages.properties b/server/skillhub-app/src/main/resources/messages.properties index c350aff7..0f807c91 100644 --- a/server/skillhub-app/src/main/resources/messages.properties +++ b/server/skillhub-app/src/main/resources/messages.properties @@ -102,6 +102,10 @@ 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.security.scan.retry.status=Only a failed security scan can be retried (current status: {0}) +error.security.scan.retry.disabled=Security scanning is disabled; enable it before retrying +error.security.scan.retry.bundleMissing=The stored package is unavailable; upload the skill again to retry scanning +security_audit.retry.started=Security scan retry started 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 @@ -187,3 +191,10 @@ promotion.sort.field.invalid=Unsupported promotion sort field: {0} promotion.sort.direction.invalid=Unsupported promotion sort direction: {0} promotion.sort.pending_unsupported=Pending promotion requests do not support reviewed-time sorting error.skill.subscription.noPermission=You do not have permission to subscribe to this skill. +error.skillReview.notFound=Skill review not found +error.skillReview.text.required=Review text is required +error.skillReview.text.tooLong=Review text must not exceed {0} characters +error.skillReview.reason.tooLong=Moderation reason must not exceed {0} characters +error.pagination.invalid=Page must be non-negative and size must be between 1 and {0} +error.request.conflict=The data changed while this request was being processed. Refresh and try again. +error.skillReview.notInteractable=Reviews are available only for published skills diff --git a/server/skillhub-app/src/main/resources/messages_ru.properties b/server/skillhub-app/src/main/resources/messages_ru.properties index 8ea9d823..f2416492 100644 --- a/server/skillhub-app/src/main/resources/messages_ru.properties +++ b/server/skillhub-app/src/main/resources/messages_ru.properties @@ -99,6 +99,10 @@ 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.security.scan.retry.status=Повторить можно только неудачное сканирование безопасности (текущий статус: {0}) +error.security.scan.retry.disabled=Сканер безопасности отключён; включите его перед повторной попыткой +error.security.scan.retry.bundleMissing=Сохранённый пакет недоступен; загрузите скилл заново для повторного сканирования +security_audit.retry.started=Повторное сканирование безопасности запущено error.skill.publish.archived=Архивный скилл нужно восстановить перед публикацией: {0} review.withdraw.not_pending=Отозвать можно только заявки на ревью со статусом pending: {0} review.withdraw.not_submitter=Отозвать это ревью может только отправитель @@ -177,3 +181,10 @@ promotion.status.invalid=Неподдерживаемый статус прод promotion.sort.field.invalid=Неподдерживаемое поле сортировки продвижения: {0} promotion.sort.direction.invalid=Неподдерживаемое направление сортировки продвижения: {0} promotion.sort.pending_unsupported=Ожидающие заявки на продвижение не поддерживают сортировку по времени ревью +error.skillReview.notFound=Отзыв о скилле не найден +error.skillReview.text.required=Текст отзыва обязателен +error.skillReview.text.tooLong=Текст отзыва не должен превышать {0} символов +error.skillReview.reason.tooLong=Причина модерации не должна превышать {0} символов +error.pagination.invalid=Номер страницы не может быть отрицательным, а размер должен быть от 1 до {0} +error.request.conflict=Данные изменились во время обработки запроса. Обновите страницу и повторите попытку. +error.skillReview.notInteractable=Отзывы доступны только для опубликованных навыков diff --git a/server/skillhub-app/src/main/resources/messages_zh.properties b/server/skillhub-app/src/main/resources/messages_zh.properties index 25b879ef..5fb0f83f 100644 --- a/server/skillhub-app/src/main/resources/messages_zh.properties +++ b/server/skillhub-app/src/main/resources/messages_zh.properties @@ -102,6 +102,10 @@ 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.security.scan.retry.status=只有安全扫描失败的版本才能重试(当前状态:{0}) +error.security.scan.retry.disabled=安全扫描器未启用,请启用后再重试 +error.security.scan.retry.bundleMissing=原技能包已不存在,请重新上传技能后再扫描 +security_audit.retry.started=已重新发起安全扫描 error.skill.publish.archived=该技能已归档,请先恢复后再发布:{0} review.withdraw.not_pending=只有待审核版本才能撤销审核:{0} review.withdraw.not_submitter=只有提交人本人可以撤销此次审核 @@ -187,3 +191,10 @@ promotion.sort.field.invalid=不支持的提升审核排序字段:{0} promotion.sort.direction.invalid=不支持的提升审核排序方向:{0} promotion.sort.pending_unsupported=待审核提升请求不支持按处理时间排序 error.skill.subscription.noPermission=您没有订阅此技能的权限。 +error.skillReview.notFound=未找到技能评价 +error.skillReview.text.required=评价内容不能为空 +error.skillReview.text.tooLong=评价内容不能超过 {0} 个字符 +error.skillReview.reason.tooLong=管理原因不能超过 {0} 个字符 +error.pagination.invalid=页码不能为负数,每页数量必须在 1 到 {0} 之间 +error.request.conflict=数据在请求处理期间已发生变化,请刷新后重试 +error.skillReview.notInteractable=仅已发布的技能可以评价 diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/config/SessionRecoveryConfigTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/config/SessionRecoveryConfigTest.java new file mode 100644 index 00000000..a75208c6 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/config/SessionRecoveryConfigTest.java @@ -0,0 +1,25 @@ +package com.iflytek.skillhub.config; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +import java.util.List; +import org.junit.jupiter.api.Test; +import org.springframework.data.redis.core.StringRedisTemplate; + +class SessionRecoveryConfigTest { + + @Test + void corruptSessionRemover_deletesSessionKeysWithoutReadingTheCorruptValue() { + StringRedisTemplate redisTemplate = mock(StringRedisTemplate.class); + var remover = new SessionRecoveryConfig() + .corruptSessionRemover(redisTemplate, "skillhub:session"); + + remover.remove("broken-session"); + + verify(redisTemplate).delete(List.of( + "skillhub:session:sessions:broken-session", + "skillhub:session:sessions:expires:broken-session" + )); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/config/WebMvcRateLimitConfigTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/config/WebMvcRateLimitConfigTest.java deleted file mode 100644 index daf845c8..00000000 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/config/WebMvcRateLimitConfigTest.java +++ /dev/null @@ -1,28 +0,0 @@ -package com.iflytek.skillhub.config; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.Mockito.mock; - -import com.iflytek.skillhub.notification.sse.SseEmitterManager; -import com.iflytek.skillhub.ratelimit.RateLimitInterceptor; -import org.junit.jupiter.api.Test; -import org.springframework.web.servlet.config.annotation.AsyncSupportConfigurer; - -class WebMvcRateLimitConfigTest { - - @Test - void configureAsyncSupport_shouldSetTimeoutToMatchSseTimeout() { - WebMvcRateLimitConfig config = new WebMvcRateLimitConfig(mock(RateLimitInterceptor.class)); - TestAsyncSupportConfigurer asyncSupportConfigurer = new TestAsyncSupportConfigurer(); - - config.configureAsyncSupport(asyncSupportConfigurer); - - assertThat(asyncSupportConfigurer.timeout()).isEqualTo(SseEmitterManager.defaultTimeoutMillis()); - } - - private static final class TestAsyncSupportConfigurer extends AsyncSupportConfigurer { - private Long timeout() { - return getTimeout(); - } - } -} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/LocalAuthControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/LocalAuthControllerTest.java index 2425acde..50082983 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/LocalAuthControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/LocalAuthControllerTest.java @@ -1,7 +1,6 @@ package com.iflytek.skillhub.controller; import static org.mockito.BDDMockito.given; -import static org.mockito.BDDMockito.willThrow; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; @@ -116,9 +115,6 @@ class LocalAuthControllerTest { @Test void register_rejectsInvalidEmailFormat() throws Exception { - given(localAuthService.register("bob", "Abcd123!", "not-an-email")) - .willThrow(new AuthFlowException(HttpStatus.BAD_REQUEST, "validation.auth.local.email.invalid")); - mockMvc.perform(post("/api/v1/auth/local/register") .with(csrf()) .header("Accept-Language", "zh-CN") @@ -129,14 +125,11 @@ class LocalAuthControllerTest { .andExpect(status().isBadRequest()) .andExpect(jsonPath("$.code").value(400)); - verify(localAuthService).register("bob", "Abcd123!", "not-an-email"); + verify(localAuthService, never()).register("bob", "Abcd123!", "not-an-email"); } @Test void register_rejectsBlankEmail() throws Exception { - given(localAuthService.register("bob", "Abcd123!", " ")) - .willThrow(new AuthFlowException(HttpStatus.BAD_REQUEST, "validation.auth.local.email.notBlank")); - mockMvc.perform(post("/api/v1/auth/local/register") .with(csrf()) .contentType(MediaType.APPLICATION_JSON) @@ -146,7 +139,7 @@ class LocalAuthControllerTest { .andExpect(status().isBadRequest()) .andExpect(jsonPath("$.code").value(400)); - verify(localAuthService).register("bob", "Abcd123!", " "); + verify(localAuthService, never()).register("bob", "Abcd123!", " "); } @Test @@ -277,9 +270,6 @@ class LocalAuthControllerTest { @Test void requestPasswordReset_rejectsInvalidEmailFormat() throws Exception { - willThrow(new AuthFlowException(HttpStatus.BAD_REQUEST, "validation.auth.password.reset.email.invalid")) - .given(passwordResetService).requestPasswordReset("alice"); - mockMvc.perform(post("/api/v1/auth/local/password-reset/request") .with(csrf()) .contentType(MediaType.APPLICATION_JSON) @@ -289,7 +279,7 @@ class LocalAuthControllerTest { .andExpect(status().isBadRequest()) .andExpect(jsonPath("$.code").value(400)); - verify(passwordResetService).requestPasswordReset("alice"); + verify(passwordResetService, never()).requestPasswordReset("alice"); } @Test @@ -308,9 +298,6 @@ class LocalAuthControllerTest { @Test void confirmPasswordReset_rejectsInvalidEmailFormat() throws Exception { - willThrow(new AuthFlowException(HttpStatus.BAD_REQUEST, "validation.auth.password.reset.email.invalid")) - .given(passwordResetService).confirmPasswordReset("alice", "123456", "Abcd123!"); - mockMvc.perform(post("/api/v1/auth/local/password-reset/confirm") .with(csrf()) .contentType(MediaType.APPLICATION_JSON) @@ -320,7 +307,7 @@ class LocalAuthControllerTest { .andExpect(status().isBadRequest()) .andExpect(jsonPath("$.code").value(400)); - verify(passwordResetService).confirmPasswordReset("alice", "123456", "Abcd123!"); + verify(passwordResetService, never()).confirmPasswordReset("alice", "123456", "Abcd123!"); } } diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/NamespaceBatchMemberControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/NamespaceBatchMemberControllerTest.java index aa4a542b..8ac33168 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/NamespaceBatchMemberControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/NamespaceBatchMemberControllerTest.java @@ -16,11 +16,13 @@ import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; import com.iflytek.skillhub.domain.user.UserAccount; import com.iflytek.skillhub.domain.user.UserAccountRepository; import com.iflytek.skillhub.service.NamespaceMemberCandidateService; +import com.iflytek.skillhub.service.NamespacePortalCommandAppService; 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.boot.test.mock.mockito.SpyBean; import org.springframework.http.MediaType; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.authority.SimpleGrantedAuthority; @@ -35,6 +37,8 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.BDDMockito.given; import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; @@ -67,6 +71,9 @@ class NamespaceBatchMemberControllerTest { @MockBean private NamespaceMemberCandidateService namespaceMemberCandidateService; + @SpyBean + private NamespacePortalCommandAppService namespacePortalCommandAppService; + @MockBean private DeviceAuthService deviceAuthService; @@ -187,9 +194,7 @@ class NamespaceBatchMemberControllerTest { @Test void batchAddMembers_emptyArray_returnsError() throws Exception { - // @NotEmpty on BatchMemberRequest.members triggers validation error - // Spring Boot 3.2+ raises HandlerMethodValidationException (500) rather than - // MethodArgumentNotValidException (400) for record-based @RequestBody validation + // @NotEmpty on BatchMemberRequest.members is enforced before the controller runs. mockMvc.perform(post("/api/v1/namespaces/team-a/members/batch") .with(csrf()) .with(auth("owner-1")) @@ -198,7 +203,10 @@ class NamespaceBatchMemberControllerTest { .content(""" {"members":[]} """)) - .andExpect(status().isInternalServerError()); + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(400)); + + verify(namespacePortalCommandAppService, never()).batchAddMembers(any(), any(), any()); } @Test diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/ReviewPortalControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/ReviewPortalControllerTest.java index 1724c786..a3016841 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/ReviewPortalControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/ReviewPortalControllerTest.java @@ -16,11 +16,15 @@ import com.iflytek.skillhub.domain.review.ReviewTaskStatus; import com.iflytek.skillhub.domain.skill.service.SkillDownloadService; import com.iflytek.skillhub.dto.ReviewTaskResponse; import com.iflytek.skillhub.dto.ReviewSkillDetailResponse; +import com.iflytek.skillhub.dto.ReviewProgressResponse; +import com.iflytek.skillhub.dto.ReviewProgressPageResponse; +import com.iflytek.skillhub.dto.ReviewProgressStatusCounts; import com.iflytek.skillhub.dto.SkillDetailResponse; import com.iflytek.skillhub.dto.SkillFileResponse; import com.iflytek.skillhub.dto.SkillLifecycleVersionResponse; import com.iflytek.skillhub.dto.SkillVersionResponse; import com.iflytek.skillhub.repository.GovernanceQueryRepository; +import com.iflytek.skillhub.repository.ReviewProgressQueryRepository; import com.iflytek.skillhub.service.ReviewSkillDetailAppService; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -38,6 +42,7 @@ import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.request.RequestPostProcessor; import java.util.List; +import java.time.Instant; import java.util.Map; import java.util.Optional; import java.util.Set; @@ -79,6 +84,9 @@ class ReviewPortalControllerTest { @MockBean private GovernanceQueryRepository governanceQueryRepository; + @MockBean + private ReviewProgressQueryRepository reviewProgressQueryRepository; + @MockBean private RbacService rbacService; @@ -276,6 +284,188 @@ class ReviewPortalControllerTest { verify(reviewTaskRepository, never()).findByStatus(org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any()); } + @Test + void listMyReviewProgress_isScopedToAuthenticatedAuthor() throws Exception { + var item = new ReviewProgressResponse( + 12L, + 30L, + "team-a", + "skill-a", + "1.0.0", + "REJECTED", + "Please add tests", + Instant.parse("2026-08-31T10:00:00Z"), + Instant.parse("2026-08-31T11:00:00Z"), + 2L + ); + given(reviewProgressQueryRepository.findMyProgress("author-1", null, "", 0, 20)) + .willReturn(new ReviewProgressPageResponse( + List.of(item), + 1, + 0, + 20, + new ReviewProgressStatusCounts(0, 0, 1) + )); + + mockMvc.perform(get("/api/v1/reviews/my-progress").with(auth("author-1"))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.items[0].skillSlug").value("skill-a")) + .andExpect(jsonPath("$.data.items[0].attemptCount").value(2)) + .andExpect(jsonPath("$.data.items[0].latestStatus").value("REJECTED")) + .andExpect(jsonPath("$.data.statusCounts.pending").value(0)) + .andExpect(jsonPath("$.data.statusCounts.rejected").value(1)); + + verify(reviewProgressQueryRepository).findMyProgress("author-1", null, "", 0, 20); + } + + @Test + void listMyReviewAttempts_returnsOnlyTheAuthorsVersionHistory() throws Exception { + ReviewTask latest = createReviewTask(12L, 20L, "author-1", ReviewTaskStatus.REJECTED); + setField(latest, "skillId", 30L); + setField(latest, "skillVersion", "1.0.0"); + ReviewTask previous = createReviewTask(8L, 20L, "author-1", ReviewTaskStatus.REJECTED); + setField(previous, "skillId", 30L); + setField(previous, "skillVersion", "1.0.0"); + ReviewTask otherAuthor = createReviewTask(7L, 20L, "author-2", ReviewTaskStatus.REJECTED); + setField(otherAuthor, "skillId", 30L); + setField(otherAuthor, "skillVersion", "1.0.0"); + given(reviewTaskRepository.findById(12L)).willReturn(Optional.of(latest)); + given(reviewTaskRepository.findBySubmittedByAndSkillIdAndSkillVersionOrderBySubmittedAtDescIdDesc( + "author-1", 30L, "1.0.0")) + .willReturn(List.of(latest, previous)); + given(reviewTaskRepository.findBySkillIdAndSkillVersionOrderBySubmittedAtDescIdDesc(30L, "1.0.0")) + .willReturn(List.of(latest, previous, otherAuthor)); + given(governanceQueryRepository.getReviewTaskResponses(List.of(latest, previous))) + .willReturn(List.of(toReviewResponse(latest), toReviewResponse(previous))); + + mockMvc.perform(get("/api/v1/reviews/my-progress/12/attempts").with(auth("author-1"))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.length()").value(2)) + .andExpect(jsonPath("$.data[0].id").value(12)) + .andExpect(jsonPath("$.data[1].id").value(8)) + .andExpect(jsonPath("$.data[0].submittedBy").value("author-1")) + .andExpect(jsonPath("$.data[1].submittedBy").value("author-1")); + + verify(reviewTaskRepository, never()) + .findBySkillIdAndSkillVersionOrderBySubmittedAtDescIdDesc(30L, "1.0.0"); + } + + @Test + void listReviewAttempts_allowsAuthorizedReviewerToReadVersionHistory() throws Exception { + ReviewTask latest = createReviewTask(12L, 20L, "author-1", ReviewTaskStatus.PENDING); + setField(latest, "skillId", 30L); + setField(latest, "skillVersion", "1.0.0"); + ReviewTask previous = createReviewTask(8L, 20L, "author-2", ReviewTaskStatus.REJECTED); + setField(previous, "skillId", 30L); + setField(previous, "skillVersion", "1.0.0"); + Namespace namespace = createNamespace(20L, "team-a"); + stubNamespaceRoles("reviewer-1", List.of()); + given(rbacService.getUserRoleCodes("reviewer-1")).willReturn(Set.of("SKILL_ADMIN")); + given(reviewTaskRepository.findById(12L)).willReturn(Optional.of(latest)); + given(namespaceRepository.findById(20L)).willReturn(Optional.of(namespace)); + given(reviewService.canReviewNamespace( + latest, + "reviewer-1", + namespace.getType(), + Map.of(), + Set.of("SKILL_ADMIN"))).willReturn(true); + given(reviewTaskRepository.findBySkillIdAndSkillVersionOrderBySubmittedAtDescIdDesc(30L, "1.0.0")) + .willReturn(List.of(latest, previous)); + given(governanceQueryRepository.getReviewTaskResponses(List.of(latest, previous))) + .willReturn(List.of(toReviewResponse(latest), toReviewResponse(previous))); + + mockMvc.perform(get("/api/v1/reviews/12/attempts").with(auth("reviewer-1"))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.length()").value(2)) + .andExpect(jsonPath("$.data[0].id").value(12)) + .andExpect(jsonPath("$.data[1].id").value(8)) + .andExpect(jsonPath("$.data[0].submittedBy").value("author-1")) + .andExpect(jsonPath("$.data[1].submittedBy").value("author-2")); + } + + @Test + void listReviewAttempts_allowsNamespaceAdminToReadCrossAuthorHistory() throws Exception { + ReviewTask latest = createReviewTask(12L, 20L, "author-1", ReviewTaskStatus.PENDING); + setField(latest, "skillId", 30L); + setField(latest, "skillVersion", "1.0.0"); + ReviewTask previous = createReviewTask(8L, 20L, "author-2", ReviewTaskStatus.REJECTED); + setField(previous, "skillId", 30L); + setField(previous, "skillVersion", "1.0.0"); + Namespace namespace = createNamespace(20L, "team-a"); + stubNamespaceRoles("namespace-admin", List.of(new NamespaceMember( + 20L, "namespace-admin", NamespaceRole.ADMIN))); + given(rbacService.getUserRoleCodes("namespace-admin")).willReturn(Set.of()); + given(reviewTaskRepository.findById(12L)).willReturn(Optional.of(latest)); + given(namespaceRepository.findById(20L)).willReturn(Optional.of(namespace)); + given(reviewService.canReviewNamespace( + latest, + "namespace-admin", + namespace.getType(), + Map.of(20L, NamespaceRole.ADMIN), + Set.of())).willReturn(true); + given(reviewTaskRepository.findBySkillIdAndSkillVersionOrderBySubmittedAtDescIdDesc(30L, "1.0.0")) + .willReturn(List.of(latest, previous)); + given(governanceQueryRepository.getReviewTaskResponses(List.of(latest, previous))) + .willReturn(List.of(toReviewResponse(latest), toReviewResponse(previous))); + + mockMvc.perform(get("/api/v1/reviews/12/attempts").with(auth("namespace-admin"))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.length()").value(2)) + .andExpect(jsonPath("$.data[0].submittedBy").value("author-1")) + .andExpect(jsonPath("$.data[1].submittedBy").value("author-2")); + } + + @Test + void listReviewAttempts_forbidsSubmitterWithoutReviewerRole() throws Exception { + ReviewTask ownAttempt = createReviewTask(12L, 20L, "author-1", ReviewTaskStatus.REJECTED); + setField(ownAttempt, "skillId", 30L); + setField(ownAttempt, "skillVersion", "1.0.0"); + Namespace namespace = createNamespace(20L, "team-a"); + stubNamespaceRoles("author-1", List.of(new NamespaceMember( + 20L, "author-1", NamespaceRole.MEMBER))); + given(rbacService.getUserRoleCodes("author-1")).willReturn(Set.of()); + given(reviewTaskRepository.findById(12L)).willReturn(Optional.of(ownAttempt)); + given(namespaceRepository.findById(20L)).willReturn(Optional.of(namespace)); + given(reviewService.canReviewNamespace( + ownAttempt, + "author-1", + namespace.getType(), + Map.of(20L, NamespaceRole.MEMBER), + Set.of())).willReturn(false); + + mockMvc.perform(get("/api/v1/reviews/12/attempts").with(auth("author-1"))) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value(403)); + + verify(reviewTaskRepository, never()) + .findBySkillIdAndSkillVersionOrderBySubmittedAtDescIdDesc(30L, "1.0.0"); + } + + @Test + void listReviewAttempts_forbidsUnrelatedUser() throws Exception { + ReviewTask latest = createReviewTask(12L, 20L, "author-1", ReviewTaskStatus.PENDING); + setField(latest, "skillId", 30L); + setField(latest, "skillVersion", "1.0.0"); + Namespace namespace = createNamespace(20L, "team-a"); + stubNamespaceRoles("other-user", List.of()); + given(rbacService.getUserRoleCodes("other-user")).willReturn(Set.of()); + given(reviewTaskRepository.findById(12L)).willReturn(Optional.of(latest)); + given(namespaceRepository.findById(20L)).willReturn(Optional.of(namespace)); + given(reviewService.canReviewNamespace( + latest, + "other-user", + namespace.getType(), + Map.of(), + Set.of())).willReturn(false); + + mockMvc.perform(get("/api/v1/reviews/12/attempts").with(auth("other-user"))) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value(403)); + + verify(reviewTaskRepository, never()) + .findBySkillIdAndSkillVersionOrderBySubmittedAtDescIdDesc(30L, "1.0.0"); + } + @Test void downloadReviewVersion_streamsZipForAuthorizedReviewer() throws Exception { stubNamespaceRoles("admin", List.of()); diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillReviewControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillReviewControllerTest.java new file mode 100644 index 00000000..7bc413c3 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillReviewControllerTest.java @@ -0,0 +1,188 @@ +package com.iflytek.skillhub.controller; + +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; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +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.rbac.PlatformPrincipal; +import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; +import com.iflytek.skillhub.dto.PageResponse; +import com.iflytek.skillhub.dto.SkillReviewMeResponse; +import com.iflytek.skillhub.dto.SkillReviewResponse; +import com.iflytek.skillhub.service.SkillReviewAppService; +import java.util.List; +import java.util.Set; +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.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +class SkillReviewControllerTest { + + @Autowired private MockMvc mockMvc; + @MockBean private SkillReviewAppService reviewAppService; + @MockBean private NamespaceMemberRepository namespaceMemberRepository; + + @Test + void publicReviewListIsAnonymous() throws Exception { + when(reviewAppService.list(eq(10L), eq(null), any(), eq(Set.of()), eq(0), eq(20))) + .thenReturn(new PageResponse<>(List.of(), 0, 0, 20)); + + mockMvc.perform(get("/api/v1/skills/10/reviews")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.total").value(0)); + } + + @Test + void publicReviewListOmitsInternalUserAndModerationFields() throws Exception { + SkillReviewResponse review = new SkillReviewResponse( + 8L, null, "Alice", null, (short) 5, "Useful", "VISIBLE", false, + null, null, null); + when(reviewAppService.list(eq(10L), eq(null), any(), eq(Set.of()), eq(0), eq(20))) + .thenReturn(new PageResponse<>(List.of(review), 1, 0, 20)); + + mockMvc.perform(get("/api/v1/skills/10/reviews")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.items[0].displayName").value("Alice")) + .andExpect(jsonPath("$.data.items[0].userId").doesNotExist()) + .andExpect(jsonPath("$.data.items[0].moderationReason").doesNotExist()); + } + + @Test + void currentUserReviewRequiresAuthentication() throws Exception { + mockMvc.perform(get("/api/v1/skills/10/reviews/me")) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value(401)); + } + + @Test + void anonymousUserCannotUpsertOrClearReview() throws Exception { + mockMvc.perform(put("/api/v1/skills/10/reviews/me") + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"score\":4,\"reviewText\":\"Useful\"}")) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value(401)); + + mockMvc.perform(delete("/api/v1/skills/10/reviews/me") + .with(csrf())) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value(401)); + } + + @Test + void authenticatedUserCanUpsertReview() throws Exception { + var principal = principal("user-42", Set.of()); + when(reviewAppService.upsert(eq(10L), eq("user-42"), eq((short) 4), eq("Useful"), any(), eq(Set.of()))) + .thenReturn(new SkillReviewMeResponse( + true, (short) 4, true, 8L, "Useful", "VISIBLE", null, null, null)); + + mockMvc.perform(put("/api/v1/skills/10/reviews/me") + .with(authentication(authToken(principal, "ROLE_USER"))) + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"score\":4,\"reviewText\":\"Useful\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.reviewed").value(true)) + .andExpect(jsonPath("$.data.reviewText").value("Useful")); + + verify(reviewAppService).upsert(eq(10L), eq("user-42"), eq((short) 4), eq("Useful"), any(), eq(Set.of())); + } + + @Test + void reviewScoreIsRequiredByTheApiContract() throws Exception { + var principal = principal("user-42", Set.of()); + + mockMvc.perform(put("/api/v1/skills/10/reviews/me") + .with(authentication(authToken(principal, "ROLE_USER"))) + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"reviewText\":\"Useful\"}")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(400)); + } + + @Test + void reviewTextIsRequiredByTheApiContract() throws Exception { + var principal = principal("user-42", Set.of()); + + mockMvc.perform(put("/api/v1/skills/10/reviews/me") + .with(authentication(authToken(principal, "ROLE_USER"))) + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"score\":4}")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(400)); + } + + @Test + void skillAdminCanHideReview() throws Exception { + var principal = principal("admin", Set.of("SKILL_ADMIN")); + + mockMvc.perform(post("/api/v1/admin/skill-reviews/8/hide") + .with(authentication(authToken(principal, "ROLE_SKILL_ADMIN"))) + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"reason\":\"spam\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)); + + verify(reviewAppService).hide(eq(8L), eq("admin"), eq("spam"), any()); + } + + @Test + void ordinaryUserCannotHideReview() throws Exception { + var principal = principal("user-42", Set.of()); + + mockMvc.perform(post("/api/v1/admin/skill-reviews/8/hide") + .with(authentication(authToken(principal, "ROLE_USER"))) + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content("{}")) + .andExpect(status().isForbidden()); + } + + @Test + void moderationReasonRejectsMoreThanFiveHundredCharacters() throws Exception { + var principal = principal("admin", Set.of("SKILL_ADMIN")); + + mockMvc.perform(post("/api/v1/admin/skill-reviews/8/hide") + .with(authentication(authToken(principal, "ROLE_SKILL_ADMIN"))) + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"reason\":\"" + "x".repeat(501) + "\"}")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(400)); + } + + private PlatformPrincipal principal(String userId, Set roles) { + return new PlatformPrincipal(userId, userId, userId + "@example.test", null, "local", roles); + } + + private UsernamePasswordAuthenticationToken authToken(PlatformPrincipal principal, String role) { + return new UsernamePasswordAuthenticationToken( + principal, + null, + List.of(new SimpleGrantedAuthority(role)) + ); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/admin/SkillReviewModerationFlowIntegrationTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/admin/SkillReviewModerationFlowIntegrationTest.java new file mode 100644 index 00000000..6663a8a8 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/admin/SkillReviewModerationFlowIntegrationTest.java @@ -0,0 +1,165 @@ +package com.iflytek.skillhub.controller.admin; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doThrow; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.iflytek.skillhub.SkillhubApplication; +import com.iflytek.skillhub.TestRedisConfig; +import com.iflytek.skillhub.auth.device.DeviceAuthService; +import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; +import com.iflytek.skillhub.auth.rbac.RbacService; +import com.iflytek.skillhub.domain.audit.AuditLog; +import com.iflytek.skillhub.domain.audit.AuditLogRepository; +import com.iflytek.skillhub.domain.governance.GovernanceNotificationService; +import com.iflytek.skillhub.domain.namespace.Namespace; +import com.iflytek.skillhub.domain.skill.Skill; +import com.iflytek.skillhub.domain.skill.SkillVisibility; +import com.iflytek.skillhub.domain.social.SkillRating; +import com.iflytek.skillhub.domain.social.SkillRatingRepository; +import com.iflytek.skillhub.domain.social.SkillReviewStatus; +import com.iflytek.skillhub.domain.user.UserAccount; +import com.iflytek.skillhub.infra.jpa.AuditLogJpaRepository; +import com.iflytek.skillhub.infra.jpa.JpaSkillRatingRepository; +import com.iflytek.skillhub.infra.jpa.NamespaceJpaRepository; +import com.iflytek.skillhub.infra.jpa.SkillJpaRepository; +import com.iflytek.skillhub.infra.jpa.UserAccountJpaRepository; +import com.iflytek.skillhub.notification.service.NotificationService; +import java.util.List; +import java.util.Set; +import java.util.UUID; +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.boot.test.mock.mockito.SpyBean; +import org.springframework.context.annotation.Import; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +@SpringBootTest(classes = SkillhubApplication.class) +@AutoConfigureMockMvc +@ActiveProfiles("test") +@Import(TestRedisConfig.class) +class SkillReviewModerationFlowIntegrationTest { + + private static final String ADMIN_ID = "review-admin"; + private static final String AUTHOR_ID = "review-author"; + + @Autowired private MockMvc mockMvc; + @Autowired private UserAccountJpaRepository userAccountRepository; + @Autowired private NamespaceJpaRepository namespaceRepository; + @Autowired private SkillJpaRepository skillRepository; + @Autowired private SkillRatingRepository ratingRepository; + @Autowired private JpaSkillRatingRepository ratingJpaRepository; + + @Autowired private AuditLogRepository auditLogRepository; + @SpyBean private AuditLogJpaRepository auditLogJpaRepository; + @MockBean private DeviceAuthService deviceAuthService; + @MockBean private RbacService rbacService; + @MockBean private GovernanceNotificationService governanceNotificationService; + @MockBean private NotificationService notificationService; + + @Test + void hideAndRestorePersistModerationStateAndAuditRows() throws Exception { + SkillRating review = createReview(); + + mockMvc.perform(post("/api/v1/admin/skill-reviews/" + review.getId() + "/hide") + .contentType("application/json") + .content("{\"reason\":\"off topic\"}") + .with(authentication(adminAuth())) + .with(csrf())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.status").value("HIDDEN")); + + SkillRating hidden = ratingRepository.findById(review.getId()).orElseThrow(); + assertThat(hidden.getReviewStatus()).isEqualTo(SkillReviewStatus.HIDDEN); + assertThat(hidden.getModeratedBy()).isEqualTo(ADMIN_ID); + assertThat(hidden.getModerationReason()).isEqualTo("off topic"); + + mockMvc.perform(post("/api/v1/admin/skill-reviews/" + review.getId() + "/restore") + .with(authentication(adminAuth())) + .with(csrf())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.status").value("VISIBLE")); + + SkillRating saved = ratingRepository.findById(review.getId()).orElseThrow(); + assertThat(saved.getReviewStatus()).isEqualTo(SkillReviewStatus.VISIBLE); + assertThat(saved.getModeratedBy()).isEqualTo(ADMIN_ID); + + List actions = auditLogJpaRepository.findAll().stream() + .filter(log -> ADMIN_ID.equals(log.getActorUserId())) + .filter(log -> review.getId().equals(log.getTargetId())) + .filter(log -> "SKILL_REVIEW".equals(log.getTargetType())) + .map(AuditLog::getAction) + .toList(); + assertThat(actions).contains("SKILL_REVIEW_HIDE", "SKILL_REVIEW_RESTORE"); + } + + @Test + void hideRollsBackModerationWhenAuditPersistenceFails() throws Exception { + SkillRating review = createReview(); + doThrow(new DataIntegrityViolationException("forced audit failure")) + .when(auditLogRepository).save(any(AuditLog.class)); + + mockMvc.perform(post("/api/v1/admin/skill-reviews/" + review.getId() + "/hide") + .contentType("application/json") + .content("{\"reason\":\"off topic\"}") + .with(authentication(adminAuth())) + .with(csrf())) + .andExpect(status().isInternalServerError()); + + SkillRating saved = ratingRepository.findById(review.getId()).orElseThrow(); + assertThat(saved.getReviewStatus()).isEqualTo(SkillReviewStatus.VISIBLE); + assertThat(saved.getModeratedBy()).isNull(); + assertThat(saved.getModerationReason()).isNull(); + } + + private SkillRating createReview() { + String suffix = UUID.randomUUID().toString().substring(0, 8); + saveUserIfAbsent(ADMIN_ID, "Review Admin", "review-admin@example.test"); + saveUserIfAbsent(AUTHOR_ID, "Review Author", "review-author@example.test"); + + Namespace namespace = namespaceRepository.saveAndFlush( + new Namespace("review-team-" + suffix, "Review Team " + suffix, AUTHOR_ID)); + Skill skill = new Skill(namespace.getId(), "review-skill-" + suffix, AUTHOR_ID, SkillVisibility.PUBLIC); + skill.setCreatedBy(AUTHOR_ID); + skill.setUpdatedBy(AUTHOR_ID); + skill = skillRepository.saveAndFlush(skill); + + SkillRating review = new SkillRating(skill.getId(), AUTHOR_ID, (short) 4); + review.updateReview((short) 4, "Useful review"); + return ratingJpaRepository.saveAndFlush(review); + } + + private void saveUserIfAbsent(String userId, String displayName, String email) { + if (!userAccountRepository.existsById(userId)) { + userAccountRepository.saveAndFlush(new UserAccount(userId, displayName, email, null)); + } + } + + private UsernamePasswordAuthenticationToken adminAuth() { + PlatformPrincipal principal = new PlatformPrincipal( + ADMIN_ID, + "Review Admin", + "review-admin@example.test", + "", + "session", + Set.of("SKILL_ADMIN") + ); + return new UsernamePasswordAuthenticationToken( + principal, + null, + List.of(new SimpleGrantedAuthority("ROLE_SKILL_ADMIN")) + ); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/NotificationControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/NotificationControllerTest.java index f44d2941..90e195bd 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/NotificationControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/NotificationControllerTest.java @@ -11,7 +11,6 @@ import com.iflytek.skillhub.dto.PageResponse; import com.iflytek.skillhub.notification.domain.Notification; import com.iflytek.skillhub.notification.domain.NotificationCategory; import com.iflytek.skillhub.notification.service.NotificationService; -import com.iflytek.skillhub.notification.sse.SseEmitterManager; import com.iflytek.skillhub.observability.RequestIdAccessor; import java.time.Clock; import java.time.Instant; @@ -32,9 +31,6 @@ class NotificationControllerTest { @Mock private NotificationService notificationService; - @Mock - private SseEmitterManager sseEmitterManager; - private NotificationController controller; @BeforeEach @@ -46,7 +42,7 @@ class NotificationControllerTest { Clock.fixed(Instant.parse("2026-03-20T00:00:00Z"), ZoneOffset.UTC), new RequestIdAccessor() ); - controller = new NotificationController(notificationService, sseEmitterManager, new ObjectMapper(), responseFactory); + controller = new NotificationController(notificationService, new ObjectMapper(), responseFactory); } @Test diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/PromotionApprovalFlowIntegrationTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/PromotionApprovalFlowIntegrationTest.java index ca543a37..7d9249b1 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/PromotionApprovalFlowIntegrationTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/PromotionApprovalFlowIntegrationTest.java @@ -21,7 +21,7 @@ import com.iflytek.skillhub.infra.jpa.PromotionRequestJpaRepository; import com.iflytek.skillhub.infra.jpa.SkillJpaRepository; import com.iflytek.skillhub.infra.jpa.SkillVersionJpaRepository; import com.iflytek.skillhub.infra.jpa.UserAccountJpaRepository; -import com.iflytek.skillhub.notification.service.NotificationDispatcher; +import com.iflytek.skillhub.notification.service.NotificationService; import java.time.Instant; import java.util.List; import java.util.Set; @@ -91,7 +91,7 @@ class PromotionApprovalFlowIntegrationTest { private GovernanceNotificationService governanceNotificationService; @MockBean - private NotificationDispatcher notificationDispatcher; + private NotificationService notificationService; @MockBean private AuditLogRepository auditLogRepository; diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SecurityAuditControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SecurityAuditControllerTest.java index eb5f87d6..e19be679 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SecurityAuditControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SecurityAuditControllerTest.java @@ -15,6 +15,8 @@ import com.iflytek.skillhub.domain.skill.SkillStatus; import com.iflytek.skillhub.domain.skill.SkillVersion; import com.iflytek.skillhub.domain.skill.SkillVersionRepository; import com.iflytek.skillhub.domain.skill.SkillVisibility; +import com.iflytek.skillhub.dto.SkillLifecycleMutationResponse; +import com.iflytek.skillhub.service.SecurityScanRetryAppService; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; @@ -34,7 +36,9 @@ import java.util.Set; import static org.mockito.BDDMockito.given; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -61,6 +65,37 @@ class SecurityAuditControllerTest { @MockBean private NamespaceMemberRepository namespaceMemberRepository; + @MockBean + private SecurityScanRetryAppService securityScanRetryAppService; + + @Test + void retrySecurityScan_returnsScanningState() throws Exception { + given(securityScanRetryAppService.retry( + org.mockito.ArgumentMatchers.eq(8L), + org.mockito.ArgumentMatchers.eq(42L), + org.mockito.ArgumentMatchers.eq("owner-1"), + org.mockito.ArgumentMatchers.eq(Set.of()), + org.mockito.ArgumentMatchers.anyMap(), + org.mockito.ArgumentMatchers.any())) + .willReturn(new SkillLifecycleMutationResponse(8L, 42L, "RETRY_SECURITY_SCAN", "SCANNING")); + + mockMvc.perform(post("/api/v1/skills/8/versions/42/security-audit/retry") + .with(auth("owner-1")) + .with(csrf()) + .requestAttr("userNsRoles", Map.of())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.action").value("RETRY_SECURITY_SCAN")) + .andExpect(jsonPath("$.data.status").value("SCANNING")); + } + + @Test + void retrySecurityScan_requiresAuthentication() throws Exception { + mockMvc.perform(post("/api/v1/skills/8/versions/42/security-audit/retry").with(csrf())) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value(401)); + } + @Test void getSecurityAudit_returnsAuditPayload() throws Exception { SecurityAudit audit = new SecurityAudit(42L, ScannerType.SKILL_SCANNER); diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillApprovalVisibilityFlowIntegrationTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillApprovalVisibilityFlowIntegrationTest.java index 081f5133..ee467c27 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillApprovalVisibilityFlowIntegrationTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillApprovalVisibilityFlowIntegrationTest.java @@ -212,7 +212,8 @@ class SkillApprovalVisibilityFlowIntegrationTest { version = skillVersionRepository.save(version); skillVersionRepository.flush(); - ReviewTask reviewTask = reviewTaskJpaRepository.saveAndFlush(new ReviewTask(version.getId(), namespace.getId(), ownerId)); + ReviewTask reviewTask = reviewTaskJpaRepository.saveAndFlush(new ReviewTask( + version.getId(), skill.getId(), namespace.getId(), version.getVersion(), ownerId)); return new PendingSkillGraph(namespace, skill, version, reviewTask); } @@ -237,7 +238,8 @@ class SkillApprovalVisibilityFlowIntegrationTest { version = skillVersionRepository.save(version); skillVersionRepository.flush(); - ReviewTask reviewTask = reviewTaskJpaRepository.saveAndFlush(new ReviewTask(version.getId(), namespace.getId(), ownerId)); + ReviewTask reviewTask = reviewTaskJpaRepository.saveAndFlush(new ReviewTask( + version.getId(), skill.getId(), namespace.getId(), version.getVersion(), ownerId)); return new PendingSkillGraph(namespace, skill, version, reviewTask); } diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillVersionDeleteFlowIntegrationTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillVersionDeleteFlowIntegrationTest.java index 25d585e5..65fbaf15 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillVersionDeleteFlowIntegrationTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillVersionDeleteFlowIntegrationTest.java @@ -91,15 +91,18 @@ class SkillVersionDeleteFlowIntegrationTest { retainedVersion.setStatus(SkillVersionStatus.REJECTED); retainedVersion = skillVersionRepository.save(retainedVersion); - ReviewTask rejectedTask = new ReviewTask(rejectedVersion.getId(), namespace.getId(), ownerId); + ReviewTask rejectedTask = new ReviewTask( + rejectedVersion.getId(), skill.getId(), namespace.getId(), rejectedVersion.getVersion(), ownerId); rejectedTask.setStatus(ReviewTaskStatus.REJECTED); rejectedTask = reviewTaskRepository.save(rejectedTask); - ReviewTask approvedTask = new ReviewTask(rejectedVersion.getId(), namespace.getId(), ownerId); + ReviewTask approvedTask = new ReviewTask( + rejectedVersion.getId(), skill.getId(), namespace.getId(), rejectedVersion.getVersion(), ownerId); approvedTask.setStatus(ReviewTaskStatus.APPROVED); approvedTask = reviewTaskRepository.save(approvedTask); - ReviewTask retainedTask = new ReviewTask(retainedVersion.getId(), namespace.getId(), ownerId); + ReviewTask retainedTask = new ReviewTask( + retainedVersion.getId(), skill.getId(), namespace.getId(), retainedVersion.getVersion(), ownerId); retainedTask.setStatus(ReviewTaskStatus.REJECTED); retainedTask = reviewTaskRepository.save(retainedTask); diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/exception/GlobalExceptionHandlerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/exception/GlobalExceptionHandlerTest.java index 1cfcee32..f469638f 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/exception/GlobalExceptionHandlerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/exception/GlobalExceptionHandlerTest.java @@ -14,6 +14,7 @@ import com.iflytek.skillhub.dto.ApiResponseFactory; import com.iflytek.skillhub.metrics.SkillHubMetrics; import com.iflytek.skillhub.observability.RequestIdAccessor; import com.iflytek.skillhub.security.SensitiveLogSanitizer; +import com.iflytek.skillhub.domain.social.SkillRating; import com.iflytek.skillhub.storage.StorageAccessException; import jakarta.servlet.http.HttpServletRequest; import java.time.Clock; @@ -34,6 +35,7 @@ import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.orm.ObjectOptimisticLockingFailureException; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.web.HttpMediaTypeNotAcceptableException; import org.springframework.web.HttpMediaTypeNotSupportedException; @@ -72,6 +74,8 @@ class GlobalExceptionHandlerTest { messageSource.addMessage("error.unsupportedMediaType", java.util.Locale.getDefault(), "Unsupported media type"); messageSource.addMessage("error.notAcceptable", java.util.Locale.getDefault(), "Requested response media type is not acceptable"); + messageSource.addMessage("error.request.conflict", java.util.Locale.getDefault(), + "Refresh and try again"); requestIdAccessor = new RequestIdAccessor(); ApiResponseFactory responseFactory = new ApiResponseFactory( messageSource, @@ -95,19 +99,8 @@ class GlobalExceptionHandlerTest { } @Test - void handleAsyncRequestTimeout_shouldReturnNoContentForSseRequests() { - when(request.getRequestURI()).thenReturn("/api/v1/notifications/sse"); - - ResponseEntity response = handler.handleAsyncRequestTimeout(new AsyncRequestTimeoutException(), request); - - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT); - assertThat(response.getBody()).isNull(); - } - - @Test - void handleAsyncRequestTimeout_shouldReturnApiEnvelopeForNonSseRequests() { + void handleAsyncRequestTimeout_shouldReturnApiEnvelope() { attachAppender(); - when(request.getRequestURI()).thenReturn("/api/v1/publish"); when(request.getMethod()).thenReturn("POST"); when(sensitiveLogSanitizer.sanitizeRequestTarget(request)).thenReturn("/api/v1/publish"); @@ -144,6 +137,22 @@ class GlobalExceptionHandlerTest { .doesNotContain("userId=")); } + @Test + void persistenceConflictsReturn409WithoutLeakingDatabaseDetails() { + attachAppender(); + prepareClientErrorRequest("PUT", "/api/v1/skills/10/reviews/me"); + + ResponseEntity> response = handler.handlePersistenceConflict( + new ObjectOptimisticLockingFailureException(SkillRating.class, 7L), request); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CONFLICT); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().code()).isEqualTo(409); + assertThat(response.getBody().msg()).isEqualTo("Refresh and try again"); + assertThat(loggedMessages()).allSatisfy(message -> assertThat(message) + .doesNotContain("user-secret")); + } + @Test void handleStorageAccess_shouldLogAuthenticationWithoutStableUserId() { authenticateRequest(); diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/filter/RequestLoggingFilterTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/filter/RequestLoggingFilterTest.java index 18e6f88a..b3a4b476 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/filter/RequestLoggingFilterTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/filter/RequestLoggingFilterTest.java @@ -16,8 +16,6 @@ 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; @@ -83,31 +81,6 @@ 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 { @@ -131,27 +104,6 @@ class RequestLoggingFilterTest { assertThat(loggedMessages()).noneMatch(message -> message.contains("Headers: {")); } - @Test - void doFilterInternal_shouldBypassCachingWrapperForNotificationSse() throws Exception { - RequestLoggingFilter filter = new RequestLoggingFilter(); - MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/web/notifications/sse"); - MockHttpServletResponse response = new MockHttpServletResponse(); - AtomicReference responseSeenByChain = new AtomicReference<>(); - FilterChain chain = (servletRequest, servletResponse) -> { - responseSeenByChain.set(servletResponse); - servletResponse.getWriter().write("event: connected\n"); - servletResponse.flushBuffer(); - }; - - filter.doFilter(request, response, chain); - - assertThat(responseSeenByChain.get()).isSameAs(response); - assertThat(response.getHeader("X-Accel-Buffering")).isEqualTo("no"); - assertThat(response.getHeader(HttpHeaders.CACHE_CONTROL)).isEqualTo("no-cache, no-transform"); - assertThat(response.getContentType()).isEqualTo(MediaType.TEXT_EVENT_STREAM_VALUE); - assertThat(response.getContentAsString()).contains("event: connected"); - } - @Test void doFilterInternal_shouldKeepCachingWrapperForRegularApiResponses() throws Exception { RequestLoggingFilter filter = new RequestLoggingFilter(); diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/listener/NotificationEventListenerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/listener/NotificationEventListenerTest.java index 80304261..67028efa 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/listener/NotificationEventListenerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/listener/NotificationEventListenerTest.java @@ -19,7 +19,7 @@ import com.iflytek.skillhub.domain.user.UserAccount; import com.iflytek.skillhub.domain.user.UserAccountRepository; import com.iflytek.skillhub.domain.user.UserStatus; import com.iflytek.skillhub.notification.domain.NotificationCategory; -import com.iflytek.skillhub.notification.service.NotificationDispatcher; +import com.iflytek.skillhub.notification.service.NotificationService; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; @@ -39,7 +39,7 @@ class NotificationEventListenerTest { @Mock SkillVersionRepository skillVersionRepository; @Mock NamespaceRepository namespaceRepository; @Mock RecipientResolver recipientResolver; - @Mock NotificationDispatcher dispatcher; + @Mock NotificationService notificationService; @Mock ObjectMapper objectMapper; @Mock SkillSubscriptionService skillSubscriptionService; @Mock UserAccountRepository userAccountRepository; @@ -51,7 +51,7 @@ class NotificationEventListenerTest { @org.junit.jupiter.api.BeforeEach void setUpListener() { listener = new NotificationEventListener(skillRepository, skillVersionRepository, namespaceRepository, - recipientResolver, dispatcher, skillSubscriptionService, objectMapper, + recipientResolver, notificationService, skillSubscriptionService, objectMapper, new SubscriptionRecipientEligibility(userAccountRepository, namespaceMemberRepository, new SubscriptionMetadataAccessPolicy())); } @@ -98,7 +98,7 @@ class NotificationEventListenerTest { listener.onSkillPublished(new SkillPublishedEvent(1L, 10L, "publisher-1")); - verify(dispatcher).dispatch(eq("publisher-1"), eq(NotificationCategory.PUBLISH), + verify(notificationService).create(eq("publisher-1"), eq(NotificationCategory.PUBLISH), eq("SKILL_PUBLISHED"), anyString(), anyString(), eq("SKILL"), eq(1L)); } @@ -109,7 +109,7 @@ class NotificationEventListenerTest { listener.onSkillPublished(new SkillPublishedEvent(1L, 10L, "reviewer-1")); - verifyNoInteractions(dispatcher); + verifyNoInteractions(notificationService); } @Test @@ -120,7 +120,7 @@ class NotificationEventListenerTest { listener.onSkillPublished(new SkillPublishedEvent(1L, 10L, "reviewer-1")); - verifyNoInteractions(dispatcher); + verifyNoInteractions(notificationService); } @Test @@ -129,7 +129,7 @@ class NotificationEventListenerTest { listener.onSkillPublished(new SkillPublishedEvent(99L, 10L, "publisher-1")); - verifyNoInteractions(dispatcher); + verifyNoInteractions(notificationService); } @Test @@ -142,10 +142,10 @@ class NotificationEventListenerTest { listener.onReviewSubmitted(new ReviewSubmittedEvent(100L, 1L, 10L, "submitter-1", 5L)); - verify(dispatcher, times(2)).dispatch(anyString(), eq(NotificationCategory.REVIEW), + verify(notificationService, times(2)).create(anyString(), eq(NotificationCategory.REVIEW), eq("REVIEW_SUBMITTED"), anyString(), anyString(), eq("REVIEW"), eq(100L)); - verify(dispatcher).dispatch(eq("admin-1"), any(), any(), any(), any(), any(), any()); - verify(dispatcher).dispatch(eq("admin-2"), any(), any(), any(), any(), any(), any()); + verify(notificationService).create(eq("admin-1"), any(), any(), any(), any(), any(), any()); + verify(notificationService).create(eq("admin-2"), any(), any(), any(), any(), any(), any()); } @Test @@ -157,10 +157,10 @@ class NotificationEventListenerTest { listener.onProfileReviewSubmitted( new ProfileReviewSubmittedEvent(77L, "submitter-1", List.of("displayName"))); - verify(dispatcher, times(2)).dispatch(anyString(), eq(NotificationCategory.REVIEW), + verify(notificationService, times(2)).create(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()); + verify(notificationService).create(eq("user-admin-1"), any(), any(), any(), any(), any(), any()); + verify(notificationService).create(eq("super-admin-1"), any(), any(), any(), any(), any(), any()); } @Test @@ -172,7 +172,7 @@ class NotificationEventListenerTest { listener.onReviewApproved(new ReviewApprovedEvent(100L, 1L, 10L, "reviewer-1", "submitter-1")); - verify(dispatcher).dispatch(eq("submitter-1"), eq(NotificationCategory.REVIEW), + verify(notificationService).create(eq("submitter-1"), eq(NotificationCategory.REVIEW), eq("REVIEW_APPROVED"), anyString(), anyString(), eq("SKILL"), eq(1L)); } @@ -187,10 +187,10 @@ class NotificationEventListenerTest { listener.onPromotionSubmitted(new PromotionSubmittedEvent(200L, 1L, 10L, "submitter-1")); - verify(dispatcher, times(2)).dispatch(anyString(), eq(NotificationCategory.PROMOTION), + verify(notificationService, times(2)).create(anyString(), eq(NotificationCategory.PROMOTION), eq("PROMOTION_SUBMITTED"), anyString(), anyString(), eq("PROMOTION"), eq(200L)); - verify(dispatcher).dispatch(eq("platform-admin-1"), any(), any(), any(), any(), any(), any()); - verify(dispatcher).dispatch(eq("super-admin-1"), any(), any(), any(), any(), any(), any()); + verify(notificationService).create(eq("platform-admin-1"), any(), any(), any(), any(), any(), any()); + verify(notificationService).create(eq("super-admin-1"), any(), any(), any(), any(), any(), any()); } @Test @@ -204,7 +204,7 @@ class NotificationEventListenerTest { listener.onPromotionSubmitted(new PromotionSubmittedEvent(200L, 1L, 10L, "submitter-1")); - verify(dispatcher, times(1)).dispatch(eq("platform-admin-1"), eq(NotificationCategory.PROMOTION), + verify(notificationService, times(1)).create(eq("platform-admin-1"), eq(NotificationCategory.PROMOTION), eq("PROMOTION_SUBMITTED"), anyString(), anyString(), eq("PROMOTION"), eq(200L)); } @@ -217,7 +217,7 @@ class NotificationEventListenerTest { listener.onPromotionApproved(new PromotionApprovedEvent(200L, 1L, "self-admin", "self-admin")); - verify(dispatcher).dispatch(eq("self-admin"), eq(NotificationCategory.PROMOTION), + verify(notificationService).create(eq("self-admin"), eq(NotificationCategory.PROMOTION), eq("PROMOTION_APPROVED"), anyString(), anyString(), eq("SKILL"), eq(1L)); } @@ -230,7 +230,7 @@ class NotificationEventListenerTest { listener.onPromotionRejected(new PromotionRejectedEvent(200L, 1L, "self-admin", "self-admin", "not ready")); - verify(dispatcher).dispatch(eq("self-admin"), eq(NotificationCategory.PROMOTION), + verify(notificationService).create(eq("self-admin"), eq(NotificationCategory.PROMOTION), eq("PROMOTION_REJECTED"), anyString(), anyString(), eq("SKILL"), eq(1L)); } @@ -243,7 +243,7 @@ class NotificationEventListenerTest { listener.onReportResolved(new ReportResolvedEvent(300L, 1L, "handler-1", "reporter-1", "DISMISSED")); - verify(dispatcher).dispatch(eq("reporter-1"), eq(NotificationCategory.REPORT), + verify(notificationService).create(eq("reporter-1"), eq(NotificationCategory.REPORT), eq("REPORT_RESOLVED"), anyString(), anyString(), eq("SKILL"), eq(1L)); } @@ -260,7 +260,7 @@ class NotificationEventListenerTest { listener.onSkillPublishedForSubscribers(new SkillPublishedEvent(1L, 10L, "owner")); - verifyNoInteractions(dispatcher); + verifyNoInteractions(notificationService); } @Test @@ -275,7 +275,7 @@ class NotificationEventListenerTest { listener.onSkillPublishedForSubscribers(new SkillPublishedEvent(1L, 10L, "owner"))) .isInstanceOf(IllegalStateException.class); - verifyNoInteractions(dispatcher); + verifyNoInteractions(notificationService); } @Test @@ -300,9 +300,9 @@ class NotificationEventListenerTest { listener.onSkillPublishedForSubscribers(new SkillPublishedEvent(1L, 10L, "publisher")); - verify(dispatcher).dispatch("admin", NotificationCategory.PUBLISH, "SUBSCRIPTION_NEW_VERSION", + verify(notificationService).create("admin", NotificationCategory.PUBLISH, "SUBSCRIPTION_NEW_VERSION", "Skill updated: Test Skill", "{\"skillId\":1,\"version\":\"1.0.0\"}", "SKILL", 1L); - verifyNoMoreInteractions(dispatcher); + verifyNoMoreInteractions(notificationService); } @Test @@ -318,9 +318,9 @@ class NotificationEventListenerTest { listener.onSkillVersionYankedForSubscribers(new SkillVersionYankedEvent(1L, 10L, "actor", true)); - verify(dispatcher).dispatch("subscriber", NotificationCategory.PUBLISH, "SUBSCRIPTION_VERSION_YANKED", + verify(notificationService).create("subscriber", NotificationCategory.PUBLISH, "SUBSCRIPTION_VERSION_YANKED", "Skill version yanked: Test Skill", "{\"skillId\":1,\"versionId\":10}", "SKILL", 1L); - verifyNoMoreInteractions(dispatcher); + verifyNoMoreInteractions(notificationService); } @Test @@ -335,7 +335,7 @@ class NotificationEventListenerTest { listener.onSkillVersionYankedForSubscribers(new SkillVersionYankedEvent(1L, 10L, "actor", false)); - verifyNoInteractions(dispatcher); + verifyNoInteractions(notificationService); } @Test @@ -350,7 +350,7 @@ class NotificationEventListenerTest { listener.onSkillPublishedForSubscribers(new SkillPublishedEvent(1L, 10L, "owner"))) .isInstanceOf(IllegalStateException.class); - verifyNoInteractions(dispatcher); + verifyNoInteractions(notificationService); } @Test @@ -370,7 +370,7 @@ class NotificationEventListenerTest { listener.onSkillVersionYankedForSubscribers(new SkillVersionYankedEvent(1L, 10L, "actor", true))) .isInstanceOf(IllegalStateException.class); - verifyNoInteractions(dispatcher); + verifyNoInteractions(notificationService); } @Test @@ -391,8 +391,8 @@ class NotificationEventListenerTest { listener.onSkillVersionYankedForSubscribers(new SkillVersionYankedEvent(1L, 10L, "actor", true)); - verify(dispatcher).dispatch(eq("current"), eq(NotificationCategory.PUBLISH), + verify(notificationService).create(eq("current"), eq(NotificationCategory.PUBLISH), eq("SUBSCRIPTION_VERSION_YANKED"), anyString(), eq("{}"), eq("SKILL"), eq(1L)); - verifyNoMoreInteractions(dispatcher); + verifyNoMoreInteractions(notificationService); } } diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/listener/SubscriberNotificationSinkTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/listener/SubscriberNotificationSinkTest.java index 93385750..015aad8b 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/listener/SubscriberNotificationSinkTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/listener/SubscriberNotificationSinkTest.java @@ -19,26 +19,18 @@ import com.iflytek.skillhub.domain.social.SubscriptionRecipientEligibility; import com.iflytek.skillhub.domain.user.UserAccount; import com.iflytek.skillhub.domain.user.UserAccountRepository; import com.iflytek.skillhub.domain.user.UserStatus; -import com.iflytek.skillhub.notification.domain.Notification; import com.iflytek.skillhub.notification.domain.NotificationCategory; -import com.iflytek.skillhub.notification.domain.NotificationChannel; -import com.iflytek.skillhub.notification.service.NotificationDispatcher; -import com.iflytek.skillhub.notification.service.NotificationPreferenceService; import com.iflytek.skillhub.notification.service.NotificationService; -import com.iflytek.skillhub.notification.sse.SseEmitterManager; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.EnumSource; import org.junit.jupiter.params.provider.ValueSource; import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.ArgumentCaptor; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import java.time.Instant; import java.util.List; -import java.util.Map; import java.util.Optional; import static org.assertj.core.api.Assertions.assertThat; @@ -59,7 +51,6 @@ class SubscriberNotificationSinkTest { private static final Long SKILL_ID = 1L; private static final Long NAMESPACE_ID = 5L; private static final Long VERSION_ID = 10L; - private static final Instant CREATED_AT = Instant.parse("2026-08-19T20:30:00Z"); @Mock SkillRepository skillRepository; @Mock SkillVersionRepository skillVersionRepository; @@ -69,8 +60,6 @@ class SubscriberNotificationSinkTest { @Mock UserAccountRepository accountRepository; @Mock NamespaceMemberRepository memberRepository; @Mock NotificationService notificationService; - @Mock NotificationPreferenceService preferenceService; - @Mock SseEmitterManager sseEmitterManager; private NotificationEventListener listener; @@ -78,14 +67,12 @@ class SubscriberNotificationSinkTest { void setUp() { SubscriptionRecipientEligibility eligibility = new SubscriptionRecipientEligibility( accountRepository, memberRepository, new SubscriptionMetadataAccessPolicy()); - NotificationDispatcher dispatcher = new NotificationDispatcher( - notificationService, preferenceService, sseEmitterManager); listener = new NotificationEventListener(skillRepository, skillVersionRepository, namespaceRepository, - recipientResolver, dispatcher, subscriptionService, new ObjectMapper(), eligibility); + recipientResolver, notificationService, subscriptionService, new ObjectMapper(), eligibility); } @Test - void publishPersistsAndPushesOnlyCurrentEligibleNonPublisherAcrossAuthorizationMatrix() { + void publishNotifiesOnlyCurrentEligibleNonPublisherAcrossAuthorizationMatrix() { Skill skill = skill(SkillVisibility.PRIVATE, false, VERSION_ID); Namespace namespace = namespace(NamespaceStatus.ACTIVE); List candidates = List.of("publisher", "current-admin", "stale-removed", "inactive", @@ -98,20 +85,18 @@ class SubscriberNotificationSinkTest { when(memberRepository.findByNamespaceIdAndUserIdIn(NAMESPACE_ID, candidates)).thenReturn(List.of( member("current-admin", NamespaceRole.ADMIN), member("private-member", NamespaceRole.MEMBER))); - enablePersistenceFor("current-admin", "SUBSCRIPTION_NEW_VERSION"); listener.onSkillPublishedForSubscribers(new SkillPublishedEvent(SKILL_ID, VERSION_ID, "publisher")); String body = "{\"skillId\":1,\"skillName\":\"Test Skill\",\"slug\":\"test-skill\",\"namespace\":\"demo\"}"; verify(notificationService).create("current-admin", NotificationCategory.PUBLISH, "SUBSCRIPTION_NEW_VERSION", "Skill updated: Test Skill", body, "SKILL", SKILL_ID); - assertSingleSse("current-admin", "SUBSCRIPTION_NEW_VERSION", body); verify(accountRepository).findByIdIn(candidates); verify(memberRepository).findByNamespaceIdAndUserIdIn(NAMESPACE_ID, candidates); } @Test - void hiddenPublishPersistsAndPushesOnlyManagerWhileOrdinaryAndPlatformOnlyCandidatesStayAtZero() { + void hiddenPublishNotifiesOnlyManagerWhileOrdinaryAndPlatformOnlyCandidatesStayAtZero() { Skill skill = skill(SkillVisibility.PUBLIC, true, VERSION_ID); Namespace namespace = namespace(NamespaceStatus.ACTIVE); List candidates = List.of("manager", "ordinary-member", "platform-super-admin"); @@ -120,21 +105,19 @@ class SubscriberNotificationSinkTest { account("manager"), account("ordinary-member"), account("platform-super-admin"))); when(memberRepository.findByNamespaceIdAndUserIdIn(NAMESPACE_ID, candidates)).thenReturn(List.of( member("manager", NamespaceRole.ADMIN), member("ordinary-member", NamespaceRole.MEMBER))); - enablePersistenceFor("manager", "SUBSCRIPTION_NEW_VERSION"); listener.onSkillPublishedForSubscribers(new SkillPublishedEvent(SKILL_ID, VERSION_ID, "publisher")); String body = "{\"skillId\":1,\"skillName\":\"Test Skill\",\"slug\":\"test-skill\",\"namespace\":\"demo\"}"; verify(notificationService).create("manager", NotificationCategory.PUBLISH, "SUBSCRIPTION_NEW_VERSION", "Skill updated: Test Skill", body, "SKILL", SKILL_ID); - assertSingleSse("manager", "SUBSCRIPTION_NEW_VERSION", body); verify(accountRepository).findByIdIn(candidates); verify(memberRepository).findByNamespaceIdAndUserIdIn(NAMESPACE_ID, candidates); } @ParameterizedTest(name = "yank wasPublished with fallback={0} reaches only current archived-namespace member") @ValueSource(booleans = {true, false}) - void yankPersistsAndPushesOnlyCurrentMemberForFallbackAndNoFallback(boolean hasFallback) { + void yankNotifiesOnlyCurrentMemberForFallbackAndNoFallback(boolean hasFallback) { Skill skill = skill(SkillVisibility.PUBLIC, false, hasFallback ? 9L : null); Namespace namespace = namespace(NamespaceStatus.ARCHIVED); List candidates = List.of("actor", "current", "removed", "inactive", "missing"); @@ -143,7 +126,6 @@ class SubscriberNotificationSinkTest { account("actor"), account("current"), account("removed"), inactiveAccount("inactive"))); when(memberRepository.findByNamespaceIdAndUserIdIn(NAMESPACE_ID, candidates)).thenReturn(List.of( member("actor", NamespaceRole.ADMIN), member("current", NamespaceRole.MEMBER))); - enablePersistenceFor("current", "SUBSCRIPTION_VERSION_YANKED"); listener.onSkillVersionYankedForSubscribers( new SkillVersionYankedEvent(SKILL_ID, VERSION_ID, "actor", true)); @@ -151,13 +133,12 @@ class SubscriberNotificationSinkTest { String body = "{\"skillId\":1,\"skillName\":\"Test Skill\",\"slug\":\"test-skill\",\"namespace\":\"demo\"}"; verify(notificationService).create("current", NotificationCategory.PUBLISH, "SUBSCRIPTION_VERSION_YANKED", "Skill version yanked: Test Skill", body, "SKILL", SKILL_ID); - assertSingleSse("current", "SUBSCRIPTION_VERSION_YANKED", body); verify(accountRepository).findByIdIn(candidates); verify(memberRepository).findByNamespaceIdAndUserIdIn(NAMESPACE_ID, candidates); } @Test - void yankWithoutVerifiedPublishedPreStateProducesNoPersistenceOrSse() { + void yankWithoutVerifiedPublishedPreStateProducesNoNotification() { Skill skill = skill(SkillVisibility.PUBLIC, false, null); Namespace namespace = namespace(NamespaceStatus.ACTIVE); List candidates = List.of("current"); @@ -168,14 +149,14 @@ class SubscriberNotificationSinkTest { listener.onSkillVersionYankedForSubscribers( new SkillVersionYankedEvent(SKILL_ID, VERSION_ID, "actor", false)); - verifyNoInteractions(notificationService, preferenceService, sseEmitterManager); + verifyNoInteractions(notificationService); verify(accountRepository).findByIdIn(candidates); verify(memberRepository).findByNamespaceIdAndUserIdIn(NAMESPACE_ID, candidates); } @ParameterizedTest(name = "{0} batch failure happens before every final sink") @EnumSource(BatchFailure.class) - void authoritativeBatchFailureProducesNoPartialPersistenceOrSse(BatchFailure failure) { + void authoritativeBatchFailureProducesNoPartialNotification(BatchFailure failure) { Skill skill = skill(SkillVisibility.PUBLIC, false, VERSION_ID); Namespace namespace = namespace(NamespaceStatus.ACTIVE); List candidates = List.of("first", "second"); @@ -199,7 +180,7 @@ class SubscriberNotificationSinkTest { .isInstanceOf(IllegalStateException.class) .hasMessageContaining(failure.name().toLowerCase()); - verifyNoInteractions(notificationService, preferenceService, sseEmitterManager); + verifyNoInteractions(notificationService); verify(namespaceRepository, times(1)).findById(NAMESPACE_ID); if (failure == BatchFailure.NAMESPACE) { verify(accountRepository, never()).findByIdIn(anyList()); @@ -217,27 +198,6 @@ class SubscriberNotificationSinkTest { when(namespaceRepository.findById(NAMESPACE_ID)).thenReturn(Optional.of(namespace)); } - private void enablePersistenceFor(String recipient, String eventType) { - when(preferenceService.isEnabled(recipient, NotificationCategory.PUBLISH, NotificationChannel.IN_APP)) - .thenReturn(true); - when(notificationService.create(eq(recipient), eq(NotificationCategory.PUBLISH), eq(eventType), - any(String.class), any(String.class), eq("SKILL"), eq(SKILL_ID))) - .thenAnswer(invocation -> notification(recipient, eventType, - invocation.getArgument(3), invocation.getArgument(4))); - } - - private void assertSingleSse(String recipient, String eventType, String body) { - @SuppressWarnings("unchecked") - ArgumentCaptor> payload = ArgumentCaptor.forClass(Map.class); - verify(sseEmitterManager).push(eq(recipient), payload.capture()); - assertThat(payload.getValue()) - .containsEntry("id", 42L) - .containsEntry("category", "PUBLISH") - .containsEntry("eventType", eventType) - .containsEntry("bodyJson", body) - .containsEntry("entityType", "SKILL") - .containsEntry("entityId", SKILL_ID); - } private Skill skill(SkillVisibility visibility, boolean hidden, Long latestVersionId) { Skill skill = new Skill(NAMESPACE_ID, "test-skill", "publisher", visibility); @@ -268,12 +228,6 @@ class SubscriberNotificationSinkTest { return new NamespaceMember(NAMESPACE_ID, userId, role); } - private Notification notification(String recipient, String eventType, String title, String body) { - Notification notification = new Notification(recipient, NotificationCategory.PUBLISH, eventType, - title, body, "SKILL", SKILL_ID, CREATED_AT); - setId(notification, 42L); - return notification; - } private void setId(Object entity, Long id) { try { diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/repository/JpaGovernanceQueryRepositoryTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/repository/JpaGovernanceQueryRepositoryTest.java index beb53df9..0d019550 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/repository/JpaGovernanceQueryRepositoryTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/repository/JpaGovernanceQueryRepositoryTest.java @@ -86,6 +86,32 @@ class JpaGovernanceQueryRepositoryTest { assertThat(responses.get(0).reviewedByName()).isEqualTo("Reviewer"); } + @Test + void getReviewTaskResponses_usesSnapshotAfterReviewedVersionIsReplaced() { + ReviewTask task = new ReviewTask(null, 201L, 11L, "1.2.0", "submitter"); + setField(task, "id", 6L); + setField(task, "status", ReviewTaskStatus.REJECTED); + + Skill skill = new Skill(11L, "skill-a", "submitter", SkillVisibility.PUBLIC); + setField(skill, "id", 201L); + Namespace namespace = new Namespace("team-a", "Team A", "submitter"); + setField(namespace, "id", 11L); + UserAccount submitter = new UserAccount("submitter", "Submitter", "submitter@example.com", null); + + given(skillRepository.findByIdIn(List.of(201L))).willReturn(List.of(skill)); + given(namespaceRepository.findByIdIn(List.of(11L))).willReturn(List.of(namespace)); + given(userAccountRepository.findByIdIn(List.of("submitter"))).willReturn(List.of(submitter)); + + var responses = repository.getReviewTaskResponses(List.of(task)); + + assertThat(responses).singleElement().satisfies(response -> { + assertThat(response.skillVersionId()).isNull(); + assertThat(response.namespace()).isEqualTo("team-a"); + assertThat(response.skillSlug()).isEqualTo("skill-a"); + assertThat(response.version()).isEqualTo("1.2.0"); + }); + } + @Test void getPromotionResponses_assemblesPromotionReadModel() { PromotionRequest request = new PromotionRequest(201L, 101L, 12L, "submitter"); diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/repository/JpaReviewProgressQueryRepositoryTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/repository/JpaReviewProgressQueryRepositoryTest.java new file mode 100644 index 00000000..b08edf39 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/repository/JpaReviewProgressQueryRepositoryTest.java @@ -0,0 +1,159 @@ +package com.iflytek.skillhub.repository; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.iflytek.skillhub.domain.namespace.Namespace; +import com.iflytek.skillhub.domain.review.ReviewTask; +import com.iflytek.skillhub.domain.review.ReviewTaskStatus; +import com.iflytek.skillhub.domain.skill.Skill; +import com.iflytek.skillhub.domain.skill.SkillVisibility; +import java.time.Instant; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.test.context.ActiveProfiles; +import org.testcontainers.containers.PostgreSQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +@DataJpaTest +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +@ActiveProfiles("test") +@Import(JpaReviewProgressQueryRepository.class) +@Testcontainers +class JpaReviewProgressQueryRepositoryTest { + + @Container + private static final PostgreSQLContainer POSTGRES = + new PostgreSQLContainer<>("postgres:16-alpine"); + + @DynamicPropertySource + static void configurePostgres(DynamicPropertyRegistry registry) { + registry.add("spring.datasource.url", POSTGRES::getJdbcUrl); + registry.add("spring.datasource.username", POSTGRES::getUsername); + registry.add("spring.datasource.password", POSTGRES::getPassword); + registry.add("spring.datasource.driver-class-name", () -> "org.postgresql.Driver"); + registry.add("spring.jpa.database-platform", () -> "org.hibernate.dialect.PostgreSQLDialect"); + } + + @Autowired + private TestEntityManager entityManager; + + @Autowired + private JpaReviewProgressQueryRepository repository; + + @Test + void groupsAttemptsFiltersLatestStatusAndKeepsTotalsOnEmptyPage() { + Namespace namespace = entityManager.persistFlushFind( + new Namespace("team-review", "Review Team", "owner")); + Skill alpha = entityManager.persistFlushFind( + new Skill(namespace.getId(), "alpha-skill", "author-1", SkillVisibility.PUBLIC)); + Skill beta = entityManager.persistFlushFind( + new Skill(namespace.getId(), "beta-skill", "author-1", SkillVisibility.PUBLIC)); + Skill gamma = entityManager.persistFlushFind( + new Skill(namespace.getId(), "gamma-skill", "author-1", SkillVisibility.PUBLIC)); + + persistAttempt( + alpha, + namespace, + "author-1", + "1.0.0", + ReviewTaskStatus.REJECTED, + Instant.parse("2026-08-30T10:00:00Z")); + persistAttempt( + alpha, + namespace, + "author-1", + "1.0.0", + ReviewTaskStatus.PENDING, + Instant.parse("2026-08-31T10:00:00Z")); + persistAttempt( + beta, + namespace, + "author-1", + "2.0.0", + ReviewTaskStatus.APPROVED, + Instant.parse("2026-08-29T10:00:00Z")); + persistAttempt( + gamma, + namespace, + "author-1", + "3.0.0", + ReviewTaskStatus.REJECTED, + Instant.parse("2026-08-28T10:00:00Z")); + persistAttempt( + beta, + namespace, + "other-author", + "3.0.0", + ReviewTaskStatus.REJECTED, + Instant.parse("2026-08-31T11:00:00Z")); + entityManager.flush(); + entityManager.clear(); + + var firstPage = repository.findMyProgress("author-1", null, "", 0, 1); + + assertThat(firstPage.items()).hasSize(1); + assertThat(firstPage.total()).isEqualTo(3); + assertThat(firstPage.items()).singleElement().satisfies(item -> { + assertThat(item.skillSlug()).isEqualTo("alpha-skill"); + assertThat(item.latestStatus()).isEqualTo("PENDING"); + assertThat(item.attemptCount()).isEqualTo(2); + }); + assertThat(firstPage.statusCounts().pending()).isEqualTo(1); + assertThat(firstPage.statusCounts().approved()).isEqualTo(1); + assertThat(firstPage.statusCounts().rejected()).isEqualTo(1); + + var emptyPage = repository.findMyProgress("author-1", null, "", 8, 1); + assertThat(emptyPage.items()).isEmpty(); + assertThat(emptyPage.total()).isEqualTo(3); + + var maximumPage = repository.findMyProgress( + "author-1", null, "", Integer.MAX_VALUE, 100); + assertThat(maximumPage.items()).isEmpty(); + assertThat(maximumPage.total()).isEqualTo(3); + + var searchedAndFiltered = repository.findMyProgress( + "author-1", ReviewTaskStatus.APPROVED, "BETA", 0, 20); + assertThat(searchedAndFiltered.items()).singleElement() + .satisfies(item -> assertThat(item.skillSlug()).isEqualTo("beta-skill")); + assertThat(searchedAndFiltered.total()).isEqualTo(1); + assertThat(searchedAndFiltered.statusCounts().approved()).isEqualTo(1); + + var searchMiss = repository.findMyProgress("author-1", null, "missing", 0, 20); + assertThat(searchMiss.items()).isEmpty(); + assertThat(searchMiss.total()).isZero(); + assertThat(searchMiss.statusCounts().pending()).isZero(); + assertThat(searchMiss.statusCounts().approved()).isZero(); + assertThat(searchMiss.statusCounts().rejected()).isZero(); + } + + private void persistAttempt( + Skill skill, + Namespace namespace, + String author, + String version, + ReviewTaskStatus status, + Instant submittedAt) { + ReviewTask task = new ReviewTask( + null, skill.getId(), namespace.getId(), version, author); + task.setStatus(status); + setField(task, "submittedAt", submittedAt); + entityManager.persist(task); + } + + private void setField(Object target, String fieldName, Object value) { + try { + java.lang.reflect.Field field = target.getClass().getDeclaredField(fieldName); + field.setAccessible(true); + field.set(target, value); + } catch (ReflectiveOperationException error) { + throw new AssertionError(error); + } + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/repository/JpaSkillReviewQueryRepositoryTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/repository/JpaSkillReviewQueryRepositoryTest.java new file mode 100644 index 00000000..6a8959b9 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/repository/JpaSkillReviewQueryRepositoryTest.java @@ -0,0 +1,69 @@ +package com.iflytek.skillhub.repository; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.iflytek.skillhub.domain.social.SkillRating; +import com.iflytek.skillhub.domain.social.SkillRatingRepository; +import com.iflytek.skillhub.domain.user.UserAccount; +import com.iflytek.skillhub.domain.user.UserAccountRepository; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; + +@ExtendWith(MockitoExtension.class) +class JpaSkillReviewQueryRepositoryTest { + + @Mock private SkillRatingRepository ratingRepository; + @Mock private UserAccountRepository userAccountRepository; + + @Test + void assemblesAuthorProfileWithoutPerRowUserQueries() { + SkillRating rating = new SkillRating(10L, "author-1", (short) 5); + rating.updateReview((short) 5, "excellent"); + UserAccount author = new UserAccount("author-1", "Alice", null, "avatar.png"); + PageRequest page = PageRequest.of(0, 20); + when(ratingRepository.findVisibleReviewsBySkillId(10L, page)) + .thenReturn(new PageImpl<>(List.of(rating), page, 1)); + when(userAccountRepository.findByIdIn(List.of("author-1"))).thenReturn(List.of(author)); + + var result = new JpaSkillReviewQueryRepository(ratingRepository, userAccountRepository) + .list(10L, "author-1", false, page); + + assertThat(result.getContent()).singleElement().satisfies(review -> { + assertThat(review.displayName()).isEqualTo("Alice"); + assertThat(review.avatarUrl()).isEqualTo("avatar.png"); + assertThat(review.reviewText()).isEqualTo("excellent"); + assertThat(review.authoredByViewer()).isTrue(); + assertThat(review.status()).isEqualTo("VISIBLE"); + assertThat(review.userId()).isNull(); + assertThat(review.moderationReason()).isNull(); + }); + verify(userAccountRepository).findByIdIn(List.of("author-1")); + } + + @Test + void administratorListingIncludesModerationDetails() { + SkillRating rating = new SkillRating(10L, "author-1", (short) 2); + rating.updateReview((short) 2, "off topic"); + rating.hideReview("admin", "Policy violation"); + PageRequest page = PageRequest.of(0, 20); + when(ratingRepository.findReviewsBySkillId(10L, page)) + .thenReturn(new PageImpl<>(List.of(rating), page, 1)); + when(userAccountRepository.findByIdIn(List.of("author-1"))).thenReturn(List.of()); + + var result = new JpaSkillReviewQueryRepository(ratingRepository, userAccountRepository) + .list(10L, "admin", true, page); + + assertThat(result.getContent()).singleElement().satisfies(review -> { + assertThat(review.userId()).isEqualTo("author-1"); + assertThat(review.moderationReason()).isEqualTo("Policy violation"); + assertThat(review.status()).isEqualTo("HIDDEN"); + }); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/repository/SkillRatingOptimisticLockingTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/repository/SkillRatingOptimisticLockingTest.java new file mode 100644 index 00000000..98d72631 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/repository/SkillRatingOptimisticLockingTest.java @@ -0,0 +1,196 @@ +package com.iflytek.skillhub.repository; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.iflytek.skillhub.domain.social.SkillRating; +import com.iflytek.skillhub.domain.social.SkillReviewStatus; +import jakarta.persistence.EntityManager; +import jakarta.persistence.EntityManagerFactory; +import jakarta.persistence.OptimisticLockException; +import org.hibernate.exception.ConstraintViolationException; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import org.testcontainers.containers.PostgreSQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +@DataJpaTest +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +@ActiveProfiles("test") +@Testcontainers +class SkillRatingOptimisticLockingTest { + + @Container + private static final PostgreSQLContainer POSTGRES = + new PostgreSQLContainer<>("postgres:16-alpine"); + + @DynamicPropertySource + static void configurePostgres(DynamicPropertyRegistry registry) { + registry.add("spring.datasource.url", POSTGRES::getJdbcUrl); + registry.add("spring.datasource.username", POSTGRES::getUsername); + registry.add("spring.datasource.password", POSTGRES::getPassword); + registry.add("spring.datasource.driver-class-name", () -> "org.postgresql.Driver"); + registry.add("spring.jpa.database-platform", () -> "org.hibernate.dialect.PostgreSQLDialect"); + } + + @Autowired + private EntityManagerFactory entityManagerFactory; + + @Test + @Transactional(propagation = Propagation.NOT_SUPPORTED) + void concurrentReviewUpdatesRejectTheStaleWriter() { + Long ratingId = persistRating(); + EntityManager firstManager = entityManagerFactory.createEntityManager(); + EntityManager staleManager = entityManagerFactory.createEntityManager(); + try { + firstManager.getTransaction().begin(); + staleManager.getTransaction().begin(); + SkillRating first = firstManager.find(SkillRating.class, ratingId); + SkillRating stale = staleManager.find(SkillRating.class, ratingId); + + first.hideReview("moderator", "Policy violation"); + firstManager.getTransaction().commit(); + + stale.updateReview((short) 2, "Stale update"); + assertThatThrownBy(staleManager.getTransaction()::commit) + .satisfies(error -> assertThat(hasCause(error, OptimisticLockException.class)).isTrue()); + + EntityManager verifier = entityManagerFactory.createEntityManager(); + try { + SkillRating saved = verifier.find(SkillRating.class, ratingId); + assertThat(saved.getReviewStatus()).isEqualTo(SkillReviewStatus.HIDDEN); + assertThat(saved.getModerationReason()).isEqualTo("Policy violation"); + } finally { + verifier.close(); + } + } finally { + rollbackIfActive(firstManager); + rollbackIfActive(staleManager); + firstManager.close(); + staleManager.close(); + deleteRating(ratingId); + } + } + + @Test + @Transactional(propagation = Propagation.NOT_SUPPORTED) + void duplicateFirstInsertIsRejectedByTheDatabaseUniqueConstraint() { + EntityManager firstManager = entityManagerFactory.createEntityManager(); + EntityManager secondManager = entityManagerFactory.createEntityManager(); + try { + firstManager.getTransaction().begin(); + secondManager.getTransaction().begin(); + assertThat(countRatings(firstManager, 20L, "duplicate-author")).isZero(); + assertThat(countRatings(secondManager, 20L, "duplicate-author")).isZero(); + + SkillRating first = new SkillRating(20L, "duplicate-author", (short) 4); + first.updateReview((short) 4, "First insert"); + firstManager.persist(first); + firstManager.getTransaction().commit(); + + SkillRating duplicate = new SkillRating(20L, "duplicate-author", (short) 5); + duplicate.updateReview((short) 5, "Duplicate insert"); + assertThatThrownBy(() -> { + secondManager.persist(duplicate); + secondManager.flush(); + secondManager.getTransaction().commit(); + }).satisfies(error -> assertThat(hasCause(error, ConstraintViolationException.class)).isTrue()); + + EntityManager verifier = entityManagerFactory.createEntityManager(); + try { + assertThat(countRatings(verifier, 20L, "duplicate-author")).isEqualTo(1L); + } finally { + verifier.close(); + } + } finally { + rollbackIfActive(firstManager); + rollbackIfActive(secondManager); + firstManager.close(); + secondManager.close(); + deleteRatings(20L, "duplicate-author"); + } + } + + private Long persistRating() { + EntityManager entityManager = entityManagerFactory.createEntityManager(); + try { + entityManager.getTransaction().begin(); + SkillRating rating = new SkillRating(10L, "author", (short) 4); + rating.updateReview((short) 4, "Original review"); + entityManager.persist(rating); + entityManager.getTransaction().commit(); + return rating.getId(); + } finally { + rollbackIfActive(entityManager); + entityManager.close(); + } + } + + private void rollbackIfActive(EntityManager entityManager) { + if (entityManager.getTransaction().isActive()) { + entityManager.getTransaction().rollback(); + } + } + + private void deleteRating(Long ratingId) { + EntityManager entityManager = entityManagerFactory.createEntityManager(); + try { + entityManager.getTransaction().begin(); + SkillRating rating = entityManager.find(SkillRating.class, ratingId); + if (rating != null) { + entityManager.remove(rating); + } + entityManager.getTransaction().commit(); + } finally { + rollbackIfActive(entityManager); + entityManager.close(); + } + } + + private long countRatings(EntityManager entityManager, Long skillId, String userId) { + return entityManager.createQuery(""" + SELECT COUNT(r) FROM SkillRating r + WHERE r.skillId = :skillId AND r.userId = :userId + """, Long.class) + .setParameter("skillId", skillId) + .setParameter("userId", userId) + .getSingleResult(); + } + + private void deleteRatings(Long skillId, String userId) { + EntityManager entityManager = entityManagerFactory.createEntityManager(); + try { + entityManager.getTransaction().begin(); + entityManager.createQuery(""" + DELETE FROM SkillRating r + WHERE r.skillId = :skillId AND r.userId = :userId + """) + .setParameter("skillId", skillId) + .setParameter("userId", userId) + .executeUpdate(); + entityManager.getTransaction().commit(); + } finally { + rollbackIfActive(entityManager); + entityManager.close(); + } + } + + private boolean hasCause(Throwable error, Class expectedType) { + Throwable current = error; + while (current != null) { + if (expectedType.isInstance(current)) { + return true; + } + current = current.getCause(); + } + return false; + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SecurityScanRetryAppServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SecurityScanRetryAppServiceTest.java new file mode 100644 index 00000000..b0b2e60a --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SecurityScanRetryAppServiceTest.java @@ -0,0 +1,183 @@ +package com.iflytek.skillhub.service; + +import com.iflytek.skillhub.domain.audit.AuditLogService; +import com.iflytek.skillhub.domain.namespace.NamespaceRole; +import com.iflytek.skillhub.domain.security.ScanTask; +import com.iflytek.skillhub.domain.security.ScannerType; +import com.iflytek.skillhub.domain.security.SecurityAudit; +import com.iflytek.skillhub.domain.security.SecurityAuditRepository; +import com.iflytek.skillhub.domain.security.SecurityScanService; +import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; +import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; +import com.iflytek.skillhub.domain.skill.Skill; +import com.iflytek.skillhub.domain.skill.SkillRepository; +import com.iflytek.skillhub.domain.skill.SkillVersion; +import com.iflytek.skillhub.domain.skill.SkillVersionRepository; +import com.iflytek.skillhub.domain.skill.SkillVersionStatus; +import com.iflytek.skillhub.domain.skill.SkillVisibility; +import com.iflytek.skillhub.storage.ObjectStorageService; +import java.lang.reflect.Field; +import java.util.Map; +import java.util.Optional; +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.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +class SecurityScanRetryAppServiceTest { + + @Mock private SkillRepository skillRepository; + @Mock private SkillVersionRepository skillVersionRepository; + @Mock private SecurityAuditRepository securityAuditRepository; + @Mock private SecurityScanService securityScanService; + @Mock private ObjectStorageService objectStorageService; + @Mock private AuditLogService auditLogService; + + private SecurityScanRetryAppService service; + private Skill skill; + private SkillVersion version; + + @BeforeEach + void setUp() { + service = new SecurityScanRetryAppService( + skillRepository, + skillVersionRepository, + securityAuditRepository, + securityScanService, + objectStorageService, + auditLogService + ); + skill = skill(8L, "owner-1"); + version = version(42L, SkillVersionStatus.SCAN_FAILED); + given(skillRepository.findById(8L)).willReturn(Optional.of(skill)); + } + + @Test + void retry_asOwnerCreatesNewAttemptAndAuditLog() { + given(skillVersionRepository.findStatusByIdAndSkillId(42L, 8L)) + .willReturn(Optional.of(SkillVersionStatus.SCAN_FAILED)); + given(skillVersionRepository.findByIdForUpdate(42L)).willReturn(Optional.of(version)); + given(securityScanService.isEnabled()).willReturn(true); + given(objectStorageService.exists("packages/8/42/bundle.zip")).willReturn(true); + given(securityScanService.retryStoredBundleScan(version, "packages/8/42/bundle.zip", "owner-1")) + .willReturn(new ScanTask("task-new", 42L, null, "packages/8/42/bundle.zip", + "owner-1", 1L, Map.of())); + + var result = service.retry( + 8L, 42L, "owner-1", Set.of(), Map.of(), new AuditRequestContext("127.0.0.1", "test")); + + assertThat(result.status()).isEqualTo("SCANNING"); + verify(securityScanService).retryStoredBundleScan(version, "packages/8/42/bundle.zip", "owner-1"); + verify(auditLogService).record( + "owner-1", "RETRY_SECURITY_SCAN", "SKILL_VERSION", 42L, + null, "127.0.0.1", "test", "{\"taskId\":\"task-new\",\"version\":\"1.0.0\"}"); + } + + @Test + void retry_allowsNamespaceAdminAndPlatformSecurityAdmin() { + given(skillVersionRepository.findStatusByIdAndSkillId(42L, 8L)) + .willReturn(Optional.of(SkillVersionStatus.SCAN_FAILED)); + given(skillVersionRepository.findByIdForUpdate(42L)).willReturn(Optional.of(version)); + given(securityScanService.isEnabled()).willReturn(true); + given(objectStorageService.exists("packages/8/42/bundle.zip")).willReturn(true); + given(securityScanService.retryStoredBundleScan(any(), any(), any())) + .willReturn(new ScanTask("task-new", 42L, null, "bundle", "admin", 1L, Map.of())); + + service.retry(8L, 42L, "namespace-admin", Set.of(), Map.of(5L, NamespaceRole.ADMIN), + new AuditRequestContext(null, null)); + version.setStatus(SkillVersionStatus.SCAN_FAILED); + service.retry(8L, 42L, "security-admin", Set.of("SKILL_ADMIN"), Map.of(), + new AuditRequestContext(null, null)); + + verify(securityScanService, org.mockito.Mockito.times(2)).retryStoredBundleScan(any(), any(), any()); + } + + @Test + void retry_rejectsUnauthorizedUserBeforeReadingVersionState() { + assertThatThrownBy(() -> service.retry( + 8L, 42L, "viewer", Set.of(), Map.of(), new AuditRequestContext(null, null))) + .isInstanceOf(DomainForbiddenException.class); + + verify(skillVersionRepository, never()).findByIdForUpdate(any()); + verify(skillVersionRepository, never()).findStatusByIdAndSkillId(any(), any()); + } + + @Test + void retry_rejectsNonFailedVersion() { + version.setStatus(SkillVersionStatus.PENDING_REVIEW); + given(skillVersionRepository.findStatusByIdAndSkillId(42L, 8L)) + .willReturn(Optional.of(SkillVersionStatus.PENDING_REVIEW)); + + assertThatThrownBy(() -> service.retry( + 8L, 42L, "owner-1", Set.of(), Map.of(), new AuditRequestContext(null, null))) + .isInstanceOf(DomainBadRequestException.class); + + verify(securityScanService, never()).retryStoredBundleScan(any(), any(), any()); + verify(skillVersionRepository, never()).findByIdForUpdate(any()); + } + + @Test + void retry_rejectsMissingStoredBundle() { + given(skillVersionRepository.findStatusByIdAndSkillId(42L, 8L)) + .willReturn(Optional.of(SkillVersionStatus.SCAN_FAILED)); + given(securityScanService.isEnabled()).willReturn(true); + + assertThatThrownBy(() -> service.retry( + 8L, 42L, "owner-1", Set.of(), Map.of(), new AuditRequestContext(null, null))) + .isInstanceOf(DomainBadRequestException.class); + + verify(securityScanService, never()).retryStoredBundleScan(any(), any(), any()); + verify(skillVersionRepository, never()).findByIdForUpdate(any()); + } + + @Test + void retry_whenAttemptAlreadyStartedReturnsCurrentStateWithoutDuplicateTask() { + version.setStatus(SkillVersionStatus.SCANNING); + given(skillVersionRepository.findStatusByIdAndSkillId(42L, 8L)) + .willReturn(Optional.of(SkillVersionStatus.SCANNING)); + given(skillVersionRepository.findByIdForUpdate(42L)).willReturn(Optional.of(version)); + given(securityScanService.isEnabled()).willReturn(true); + given(securityAuditRepository.findLatestActiveByVersionIdAndScannerType(42L, ScannerType.SKILL_SCANNER)) + .willReturn(Optional.of(new SecurityAudit(42L, ScannerType.SKILL_SCANNER, "task-existing"))); + + var result = service.retry( + 8L, 42L, "owner-1", Set.of(), Map.of(), new AuditRequestContext(null, null)); + + assertThat(result.status()).isEqualTo("SCANNING"); + verify(securityScanService, never()).retryStoredBundleScan(any(), any(), any()); + verify(auditLogService, never()).record(any(), any(), any(), any(), any(), any(), any(), any()); + } + + private Skill skill(Long id, String ownerId) { + Skill value = new Skill(5L, "demo", ownerId, SkillVisibility.PRIVATE); + setField(value, "id", id); + return value; + } + + private SkillVersion version(Long id, SkillVersionStatus status) { + SkillVersion value = new SkillVersion(8L, "1.0.0", "owner-1"); + setField(value, "id", id); + value.setStatus(status); + return value; + } + + private void setField(Object target, String name, Object value) { + try { + Field field = target.getClass().getDeclaredField(name); + field.setAccessible(true); + field.set(target, value); + } catch (ReflectiveOperationException e) { + throw new AssertionError(e); + } + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SecurityScanRetryLockingTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SecurityScanRetryLockingTest.java new file mode 100644 index 00000000..986a07ff --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SecurityScanRetryLockingTest.java @@ -0,0 +1,125 @@ +package com.iflytek.skillhub.service; + +import com.iflytek.skillhub.domain.namespace.Namespace; +import com.iflytek.skillhub.domain.skill.Skill; +import com.iflytek.skillhub.domain.skill.SkillVersion; +import com.iflytek.skillhub.domain.skill.SkillVersionStatus; +import com.iflytek.skillhub.domain.skill.SkillVisibility; +import com.iflytek.skillhub.domain.user.UserAccount; +import com.iflytek.skillhub.infra.jpa.SkillVersionJpaRepository; +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionTemplate; +import org.testcontainers.containers.PostgreSQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +import static org.assertj.core.api.Assertions.assertThat; + +@DataJpaTest +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +@ActiveProfiles("test") +@Testcontainers +class SecurityScanRetryLockingTest { + + @Container + private static final PostgreSQLContainer POSTGRES = + new PostgreSQLContainer<>("postgres:16-alpine"); + + @DynamicPropertySource + static void configurePostgres(DynamicPropertyRegistry registry) { + registry.add("spring.datasource.url", POSTGRES::getJdbcUrl); + registry.add("spring.datasource.username", POSTGRES::getUsername); + registry.add("spring.datasource.password", POSTGRES::getPassword); + registry.add("spring.datasource.driver-class-name", () -> "org.postgresql.Driver"); + registry.add("spring.jpa.database-platform", () -> "org.hibernate.dialect.PostgreSQLDialect"); + } + + @Autowired + private SkillVersionJpaRepository skillVersionRepository; + + @Autowired + private PlatformTransactionManager transactionManager; + + @PersistenceContext + private EntityManager entityManager; + + @Test + @Transactional(propagation = Propagation.NOT_SUPPORTED) + void lockReadSeesStateCommittedWhileWaitingInsteadOfCachedPreflightEntity() throws Exception { + Fixture fixture = persistFailedVersion(); + Long versionId = fixture.versionId(); + CountDownLatch firstLocked = new CountDownLatch(1); + CountDownLatch secondAboutToLock = new CountDownLatch(1); + CountDownLatch releaseFirst = new CountDownLatch(1); + TransactionTemplate transactions = new TransactionTemplate(transactionManager); + + try (var executor = Executors.newVirtualThreadPerTaskExecutor()) { + var first = executor.submit(() -> transactions.executeWithoutResult(status -> { + SkillVersion version = skillVersionRepository.findByIdForUpdate(versionId).orElseThrow(); + version.setStatus(SkillVersionStatus.SCANNING); + firstLocked.countDown(); + await(releaseFirst); + })); + + assertThat(firstLocked.await(10, TimeUnit.SECONDS)).isTrue(); + var second = executor.submit(() -> transactions.execute(status -> { + assertThat(skillVersionRepository.findStatusByIdAndSkillId(versionId, fixture.skillId())) + .contains(SkillVersionStatus.SCAN_FAILED); + secondAboutToLock.countDown(); + return skillVersionRepository.findByIdForUpdate(versionId).orElseThrow().getStatus(); + })); + + assertThat(secondAboutToLock.await(10, TimeUnit.SECONDS)).isTrue(); + releaseFirst.countDown(); + first.get(); + assertThat(second.get()).isEqualTo(SkillVersionStatus.SCANNING); + } + } + + private Fixture persistFailedVersion() { + TransactionTemplate transaction = new TransactionTemplate(transactionManager); + return transaction.execute(status -> { + UserAccount user = new UserAccount("retry-lock-user", "Retry Lock User", null, null); + entityManager.persist(user); + Namespace namespace = new Namespace("retry-lock", "Retry Lock", user.getId()); + entityManager.persist(namespace); + entityManager.flush(); + Skill skill = new Skill(namespace.getId(), "retry-lock", user.getId(), SkillVisibility.PRIVATE); + entityManager.persist(skill); + entityManager.flush(); + SkillVersion version = new SkillVersion(skill.getId(), "1.0.0", user.getId()); + version.setStatus(SkillVersionStatus.SCAN_FAILED); + entityManager.persist(version); + entityManager.flush(); + return new Fixture(version.getId(), skill.getId()); + }); + } + + private void await(CountDownLatch latch) { + try { + if (!latch.await(10, TimeUnit.SECONDS)) { + throw new IllegalStateException("Timed out waiting for concurrent retry test"); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("Concurrent retry test interrupted", e); + } + } + + private record Fixture(Long versionId, Long skillId) { + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillReviewAppServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillReviewAppServiceTest.java new file mode 100644 index 00000000..be792eed --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillReviewAppServiceTest.java @@ -0,0 +1,198 @@ +package com.iflytek.skillhub.service; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.iflytek.skillhub.domain.audit.AuditLogService; +import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; +import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; +import com.iflytek.skillhub.domain.skill.Skill; +import com.iflytek.skillhub.domain.skill.SkillRepository; +import com.iflytek.skillhub.domain.skill.SkillStatus; +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.VisibilityChecker; +import com.iflytek.skillhub.domain.social.SkillRating; +import com.iflytek.skillhub.domain.social.SkillRatingService; +import com.iflytek.skillhub.observability.RequestIdAccessor; +import com.iflytek.skillhub.repository.SkillReviewQueryRepository; +import java.util.Map; +import java.util.Optional; +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 org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +@ExtendWith(MockitoExtension.class) +class SkillReviewAppServiceTest { + + @Mock private SkillRepository skillRepository; + @Mock private SkillVersionRepository skillVersionRepository; + @Mock private SkillRatingService ratingService; + @Mock private SkillReviewQueryRepository queryRepository; + @Mock private AuditLogService auditLogService; + @Mock private RequestIdAccessor requestIdAccessor; + + private SkillReviewAppService service; + + @BeforeEach + void setUp() { + service = new SkillReviewAppService( + skillRepository, + skillVersionRepository, + new VisibilityChecker(), + ratingService, + queryRepository, + auditLogService, + requestIdAccessor + ); + } + + @Test + void publicListingExcludesHiddenReviewsForRegularViewer() { + Skill skill = publishedSkill(SkillVisibility.PUBLIC); + when(skillRepository.findById(10L)).thenReturn(Optional.of(skill)); + when(queryRepository.list(eq(10L), eq(null), eq(false), any(Pageable.class))) + .thenReturn(Page.empty()); + + service.list(10L, null, Map.of(), Set.of(), 0, 20); + + verify(queryRepository).list(eq(10L), eq(null), eq(false), any(Pageable.class)); + } + + @Test + void skillAdminListingIncludesHiddenReviews() { + Skill skill = publishedSkill(SkillVisibility.PUBLIC); + when(skillRepository.findById(10L)).thenReturn(Optional.of(skill)); + when(queryRepository.list(eq(10L), eq("admin"), eq(true), any(Pageable.class))) + .thenReturn(Page.empty()); + + service.list(10L, "admin", Map.of(), Set.of("SKILL_ADMIN"), 0, 20); + + verify(queryRepository).list(eq(10L), eq("admin"), eq(true), any(Pageable.class)); + } + + @Test + void reviewPaginationRejectsNegativePageAndSizesOutsideOneToOneHundred() { + Skill skill = publishedSkill(SkillVisibility.PUBLIC); + when(skillRepository.findById(10L)).thenReturn(Optional.of(skill)); + + assertThatThrownBy(() -> service.list(10L, null, Map.of(), Set.of(), -1, 20)) + .isInstanceOf(DomainBadRequestException.class); + assertThatThrownBy(() -> service.list(10L, null, Map.of(), Set.of(), 0, 0)) + .isInstanceOf(DomainBadRequestException.class); + assertThatThrownBy(() -> service.list(10L, null, Map.of(), Set.of(), 0, 101)) + .isInstanceOf(DomainBadRequestException.class); + + verify(queryRepository, never()).list(any(), any(), anyBoolean(), any(Pageable.class)); + } + + @Test + void privateSkillReviewMutationRequiresSkillAccess() { + Skill skill = publishedSkill(SkillVisibility.PRIVATE); + when(skillRepository.findById(10L)).thenReturn(Optional.of(skill)); + + assertThatThrownBy(() -> service.upsert( + 10L, "other-user", (short) 5, "great", Map.of(), Set.of())) + .isInstanceOf(DomainForbiddenException.class); + + verify(ratingService, never()).upsertReview(any(), any(), any(Short.class), any()); + } + + @Test + void unpublishedSkillReviewMutationIsRejectedServerSide() { + Skill skill = publishedSkill(SkillVisibility.PUBLIC); + SkillVersion pending = new SkillVersion(10L, "1.0.0", "owner"); + pending.setStatus(SkillVersionStatus.PENDING_REVIEW); + when(skillRepository.findById(10L)).thenReturn(Optional.of(skill)); + when(skillVersionRepository.findById(100L)).thenReturn(Optional.of(pending)); + + assertThatThrownBy(() -> service.upsert( + 10L, "owner", (short) 5, "not published", Map.of(), Set.of())) + .isInstanceOf(com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException.class) + .hasMessage("error.skillReview.notInteractable"); + + verify(ratingService, never()).upsertReview(any(), any(), any(Short.class), any()); + } + + @Test + void archivedSkillReviewMutationIsRejectedServerSide() { + Skill skill = publishedSkill(SkillVisibility.PUBLIC); + skill.setStatus(SkillStatus.ARCHIVED); + SkillVersion published = new SkillVersion(10L, "1.0.0", "owner"); + published.setStatus(SkillVersionStatus.PUBLISHED); + when(skillRepository.findById(10L)).thenReturn(Optional.of(skill)); + when(skillVersionRepository.findById(100L)).thenReturn(Optional.of(published)); + + assertThatThrownBy(() -> service.upsert( + 10L, "owner", (short) 5, "archived", Map.of(), Set.of())) + .isInstanceOf(DomainBadRequestException.class) + .hasMessage("error.skillReview.notInteractable"); + + verify(ratingService, never()).upsertReview(any(), any(), any(Short.class), any()); + } + + @Test + void authorCanClearReviewAfterSkillStopsBeingInteractable() { + SkillRating review = new SkillRating(10L, "author", (short) 4); + review.updateReview((short) 4, "Remove me"); + review.clearReview(); + when(ratingService.clearReview(10L, "author")).thenReturn(review); + + service.clear(10L, "author", Map.of(), Set.of()); + + verify(ratingService).clearReview(10L, "author"); + verify(skillRepository, never()).findById(any()); + verify(skillVersionRepository, never()).findById(any()); + } + + @Test + void authorCanReadOwnReviewAfterSkillStopsBeingVisible() { + SkillRating review = new SkillRating(10L, "author", (short) 4); + review.updateReview((short) 4, "My review"); + when(ratingService.getUserFeedback(10L, "author")).thenReturn(Optional.of(review)); + + service.getMine(10L, "author", Map.of(), Set.of()); + + verify(ratingService).getUserFeedback(10L, "author"); + verify(skillRepository, never()).findById(any()); + } + + @Test + void hideWritesModerationAuditInSameWorkflow() { + SkillRating review = new SkillRating(10L, "author", (short) 4); + review.updateReview((short) 4, "helpful review"); + when(ratingService.hideReview(null, "admin", "spam")).thenReturn(review); + when(requestIdAccessor.current()).thenReturn("request-1"); + + service.hide(null, "admin", "spam", new AuditRequestContext("127.0.0.1", "test")); + + verify(auditLogService).record( + eq("admin"), + eq("SKILL_REVIEW_HIDE"), + eq("SKILL_REVIEW"), + eq(null), + eq("request-1"), + eq("127.0.0.1"), + eq("test"), + eq("{\"skillId\":10,\"reason\":\"spam\"}") + ); + } + + private Skill publishedSkill(SkillVisibility visibility) { + Skill skill = new Skill(1L, "demo", "owner", visibility); + skill.setLatestVersionId(100L); + return skill; + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/stream/AbstractStreamConsumerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/stream/AbstractStreamConsumerTest.java index bbb58a70..b46dc968 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/stream/AbstractStreamConsumerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/stream/AbstractStreamConsumerTest.java @@ -27,6 +27,7 @@ import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.when; class AbstractStreamConsumerTest { @@ -35,12 +36,44 @@ class AbstractStreamConsumerTest { void handleMessage_acknowledgesAfterSuccessfulProcessing() { @SuppressWarnings("unchecked") RStream stream = mock(RStream.class); - TestConsumer consumer = new TestConsumer(stream); StreamMessageId messageId = new StreamMessageId(1, 0); + when(stream.ack("scan-group", messageId)).thenReturn(1L); + TestConsumer consumer = new TestConsumer(stream); consumer.handleMessage(messageId, Map.of("payload", "ok")); verify(stream).ack("scan-group", messageId); + verify(stream).remove(messageId); + } + + @Test + void handleMessage_doesNotDeleteWhenAcknowledgementReturnsZero() { + @SuppressWarnings("unchecked") + RStream stream = mock(RStream.class); + StreamMessageId messageId = new StreamMessageId(10, 0); + when(stream.ack("scan-group", messageId)).thenReturn(0L); + TestConsumer consumer = new TestConsumer(stream); + + consumer.handleMessage(messageId, Map.of("payload", "ok")); + + verify(stream).ack("scan-group", messageId); + verify(stream, never()).remove(messageId); + } + + @Test + void handleMessage_doesNotDeleteWhenAcknowledgementFails() { + @SuppressWarnings("unchecked") + RStream stream = mock(RStream.class); + StreamMessageId messageId = new StreamMessageId(11, 0); + when(stream.ack("scan-group", messageId)) + .thenThrow(new RedisSystemException("redis unavailable", new IllegalStateException("offline"))); + TestConsumer consumer = new TestConsumer(stream); + + org.assertj.core.api.Assertions.assertThatThrownBy( + () -> consumer.handleMessage(messageId, Map.of("payload", "ok"))) + .isInstanceOf(RedisSystemException.class); + + verify(stream, never()).remove(messageId); } @Test @@ -57,6 +90,22 @@ class AbstractStreamConsumerTest { verify(stream, times(1)).ack("scan-group", messageId); } + @Test + void handleMessage_deferredFailureRemainsPending() { + @SuppressWarnings("unchecked") + RStream stream = mock(RStream.class); + TestConsumer consumer = new TestConsumer(stream); + consumer.fail = true; + consumer.defer = true; + StreamMessageId messageId = new StreamMessageId(20, 0); + + consumer.handleMessage(messageId, Map.of("payload", "busy")); + + verify(stream, never()).ack("scan-group", messageId); + verify(stream, never()).remove(messageId); + assertThat(consumer.deferred).isTrue(); + } + @Test void consumeAvailableMessages_processesNeverDeliveredMessages() { @SuppressWarnings("unchecked") @@ -140,6 +189,8 @@ class AbstractStreamConsumerTest { private final RStream stream; private final RequestIdAccessor requestIdAccessor; private boolean fail; + private boolean defer; + private boolean deferred; private String processedRequestId; private TestConsumer(RStream stream) { @@ -209,6 +260,16 @@ class AbstractStreamConsumerTest { @Override protected void retryMessage(String payload, int retryCount) { } + + @Override + protected boolean shouldDeferFailure(String payload, Exception error) { + return defer; + } + + @Override + protected void markDeferred(String payload, Exception error) { + deferred = true; + } } private static final class CountingConsumer extends TestConsumer { diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/stream/ScanTaskConsumerLoggingTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/stream/ScanTaskConsumerLoggingTest.java index 50e73da8..c388fcbf 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/stream/ScanTaskConsumerLoggingTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/stream/ScanTaskConsumerLoggingTest.java @@ -220,8 +220,19 @@ class ScanTaskConsumerLoggingTest { } @Override - public void processScanResult(Long versionId, ScannerType scannerType, SecurityScanResponse response) { + public void processScanResult(String taskId, + Long versionId, + ScannerType scannerType, + SecurityScanResponse response) { } + + @Override + public void processScanFailure(String taskId, + Long versionId, + ScannerType scannerType, + String reason) { + } + } private static final class TestProducer implements ScanTaskProducer { diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/stream/ScanTaskConsumerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/stream/ScanTaskConsumerTest.java index 07d7d9d6..eacdef88 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/stream/ScanTaskConsumerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/stream/ScanTaskConsumerTest.java @@ -18,6 +18,8 @@ import com.iflytek.skillhub.observability.MessageObservationSupport; import com.iflytek.skillhub.observability.RequestIdAccessor; import com.iflytek.skillhub.storage.ObjectStorageService; import com.iflytek.skillhub.storage.ObjectMetadata; +import com.iflytek.skillhub.infra.http.HttpClientException; +import com.iflytek.skillhub.infra.scanner.SecurityScanException; import io.micrometer.observation.ObservationRegistry; import org.junit.jupiter.api.Test; import org.redisson.api.RLock; @@ -31,8 +33,10 @@ import java.io.IOException; import java.lang.reflect.Field; import java.nio.file.Files; import java.nio.file.Path; -import java.time.Instant; +import java.time.Clock; import java.time.Duration; +import java.time.Instant; +import java.time.ZoneOffset; import java.util.Collection; import java.util.List; import java.util.Map; @@ -94,17 +98,13 @@ class ScanTaskConsumerTest { } @Test - void markFailed_setsScanFailedWithoutChangingReviewTaskAndCleansTempFile() throws Exception { - SkillVersion version = new SkillVersion(8L, "1.0.0", "publisher-1"); - setField(version, "id", 42L); - version.setStatus(SkillVersionStatus.SCANNING); - - InMemorySkillVersionRepository skillVersionRepository = new InMemorySkillVersionRepository(version); + void markFailed_recordsExactAttemptAndCleansTempFile() throws Exception { + StubSecurityScanService securityScanService = new StubSecurityScanService(); InMemoryReviewTaskRepository reviewTaskRepository = new InMemoryReviewTaskRepository(); TestableScanTaskConsumer consumer = new TestableScanTaskConsumer( new StubSecurityScanner(), - new StubSecurityScanService(), - skillVersionRepository, + securityScanService, + new InMemorySkillVersionRepository(), new InMemoryScanTaskProducer(), new InMemoryObjectStorageService() ); @@ -120,7 +120,8 @@ class ScanTaskConsumerTest { consumer.invokeMarkFailed(payload, "scan failed"); - assertThat(skillVersionRepository.savedVersion.getStatus()).isEqualTo(SkillVersionStatus.SCAN_FAILED); + assertThat(securityScanService.failedTaskId).isEqualTo("task-2"); + assertThat(securityScanService.failedVersionId).isEqualTo(42L); assertThat(reviewTaskRepository.savedTask).isNull(); assertThat(reviewTaskRepository.deletedTask).isNull(); assertThat(Files.exists(tempFile)).isFalse(); @@ -215,9 +216,10 @@ class ScanTaskConsumerTest { securityScanner.failure = new IllegalStateException("scanner unavailable"); InMemoryScanTaskProducer producer = new InMemoryScanTaskProducer(); InMemorySkillVersionRepository repository = new InMemorySkillVersionRepository(); + StubSecurityScanService scanService = new StubSecurityScanService(); TestableScanTaskConsumer consumer = new TestableScanTaskConsumer( securityScanner, - new StubSecurityScanService(), + scanService, repository, producer, objectStorageService @@ -307,7 +309,7 @@ class ScanTaskConsumerTest { } @Test - void handleMessage_whenTaskLockIsHeld_republishesInsteadOfDroppingDelivery() { + void handleMessage_whenTaskLockIsHeld_keepsOriginalDeliveryPending() { StubSecurityScanner securityScanner = new StubSecurityScanner(); InMemoryScanTaskProducer producer = new InMemoryScanTaskProducer(); RLock processingLock = mock(RLock.class); @@ -328,9 +330,179 @@ class ScanTaskConsumerTest { "scannerType", ScannerType.SKILL_SCANNER.getValue() )); - assertThat(producer.publishedTask.taskId()).isEqualTo("task-reclaimed"); - assertThat(producer.publishedTask.metadata()).containsEntry("retryCount", "1"); - verify(consumer.stream).ack("skillhub-scanners", new StreamMessageId(11, 0)); + assertThat(producer.publishedTask).isNull(); + verify(consumer.stream, never()).ack("skillhub-scanners", new StreamMessageId(11, 0)); + } + + @Test + void handleMessage_whenScannerIsUnavailable_keepsVersionScanningAndDeliveryPending() { + StubSecurityScanner securityScanner = new StubSecurityScanner(); + securityScanner.failure = new SecurityScanException( + "scanner timed out", new HttpClientException("request timed out", new java.util.concurrent.TimeoutException())); + SkillVersion version = new SkillVersion(8L, "1.0.0", "publisher-1"); + try { + setField(version, "id", 42L); + } catch (Exception e) { + throw new AssertionError(e); + } + version.setStatus(SkillVersionStatus.SCANNING); + InMemorySkillVersionRepository repository = new InMemorySkillVersionRepository(version); + InMemoryScanTaskProducer producer = new InMemoryScanTaskProducer(); + TestableScanTaskConsumer consumer = new TestableScanTaskConsumer( + securityScanner, + new StubSecurityScanService(), + repository, + producer, + new InMemoryObjectStorageService() + ); + + StreamMessageId messageId = new StreamMessageId(12, 0); + consumer.handleMessage(messageId, Map.of( + "taskId", "task-timeout", + "versionId", "42", + "skillPath", "/tmp/skillhub-scans/42", + "createdAtMillis", String.valueOf(System.currentTimeMillis()), + "scannerType", ScannerType.SKILL_SCANNER.getValue() + )); + + assertThat(version.getStatus()).isEqualTo(SkillVersionStatus.SCANNING); + assertThat(repository.savedVersion).isNull(); + assertThat(producer.publishedTask).isNull(); + verify(consumer.stream, never()).ack("skillhub-scanners", messageId); + } + + @Test + void handleMessage_whenScannerRemainsUnavailablePastRecoveryWindow_failsAndRemovesDelivery() { + StubSecurityScanner securityScanner = new StubSecurityScanner(); + securityScanner.failure = new SecurityScanException( + "scanner timed out", new HttpClientException("request timed out", new java.util.concurrent.TimeoutException())); + SkillVersion version = new SkillVersion(8L, "1.0.0", "publisher-1"); + try { + setField(version, "id", 42L); + } catch (Exception e) { + throw new AssertionError(e); + } + version.setStatus(SkillVersionStatus.SCANNING); + InMemorySkillVersionRepository repository = new InMemorySkillVersionRepository(version); + StubSecurityScanService scanService = new StubSecurityScanService(); + TestableScanTaskConsumer consumer = new TestableScanTaskConsumer( + securityScanner, + scanService, + repository, + new InMemoryScanTaskProducer(), + new InMemoryObjectStorageService(), + Clock.fixed(Instant.parse("2026-09-03T08:00:00Z"), ZoneOffset.UTC), + Duration.ofHours(1) + ); + + StreamMessageId messageId = new StreamMessageId(13, 0); + when(consumer.stream.ack("skillhub-scanners", messageId)).thenReturn(1L); + consumer.handleMessage(messageId, Map.of( + "taskId", "task-expired-timeout", + "versionId", "42", + "skillPath", "/tmp/skillhub-scans/42", + "createdAtMillis", String.valueOf(Instant.parse("2026-09-03T06:59:59Z").toEpochMilli()), + "scannerType", ScannerType.SKILL_SCANNER.getValue() + )); + + assertThat(scanService.failedTaskId).isEqualTo("task-expired-timeout"); + assertThat(scanService.failedReason).contains("Retry after scanner availability is restored"); + verify(consumer.stream).ack("skillhub-scanners", messageId); + verify(consumer.stream).remove(messageId); + } + + @Test + void handleMessage_whenScannerUnavailableBeforeRecoveryDeadline_keepsDeliveryPending() { + StubSecurityScanner securityScanner = unavailableScanner(); + SkillVersion version = scanningVersion(42L); + InMemorySkillVersionRepository repository = new InMemorySkillVersionRepository(version); + TestableScanTaskConsumer consumer = new TestableScanTaskConsumer( + securityScanner, + new StubSecurityScanService(), + repository, + new InMemoryScanTaskProducer(), + new InMemoryObjectStorageService(), + Clock.fixed(Instant.parse("2026-09-03T08:00:00Z"), ZoneOffset.UTC), + Duration.ofHours(1) + ); + + StreamMessageId messageId = new StreamMessageId(14, 0); + consumer.handleMessage(messageId, Map.of( + "taskId", "task-before-deadline", + "versionId", "42", + "skillPath", "/tmp/skillhub-scans/42", + "createdAtMillis", String.valueOf(Instant.parse("2026-09-03T07:00:01Z").toEpochMilli()), + "scannerType", ScannerType.SKILL_SCANNER.getValue() + )); + + assertThat(version.getStatus()).isEqualTo(SkillVersionStatus.SCANNING); + assertThat(repository.savedVersion).isNull(); + verify(consumer.stream, never()).ack("skillhub-scanners", messageId); + } + + @Test + void handleMessage_whenTaskTimestampIsMalformed_usesRedisEntryTimeForExpiry() { + StubSecurityScanner securityScanner = unavailableScanner(); + SkillVersion version = scanningVersion(42L); + InMemorySkillVersionRepository repository = new InMemorySkillVersionRepository(version); + StubSecurityScanService scanService = new StubSecurityScanService(); + TestableScanTaskConsumer consumer = new TestableScanTaskConsumer( + securityScanner, + scanService, + repository, + new InMemoryScanTaskProducer(), + new InMemoryObjectStorageService(), + Clock.fixed(Instant.parse("2026-09-03T08:00:00Z"), ZoneOffset.UTC), + Duration.ofHours(1) + ); + + StreamMessageId messageId = new StreamMessageId( + Instant.parse("2026-09-03T06:00:00Z").toEpochMilli(), 0); + when(consumer.stream.ack("skillhub-scanners", messageId)).thenReturn(1L); + consumer.handleMessage(messageId, Map.of( + "taskId", "task-malformed-timestamp", + "versionId", "42", + "skillPath", "/tmp/skillhub-scans/42", + "createdAtMillis", "not-a-number", + "scannerType", ScannerType.SKILL_SCANNER.getValue() + )); + + assertThat(scanService.failedTaskId).isEqualTo("task-malformed-timestamp"); + verify(consumer.stream).remove(messageId); + } + + @Test + void handleMessage_whenFailureWasRecordedButAckFails_redeliveryOnlyCompletesAck() { + StubSecurityScanner securityScanner = unavailableScanner(); + StubSecurityScanService scanService = new StubSecurityScanService(); + TestableScanTaskConsumer consumer = new TestableScanTaskConsumer( + securityScanner, + scanService, + new InMemorySkillVersionRepository(scanningVersion(42L)), + new InMemoryScanTaskProducer(), + new InMemoryObjectStorageService(), + Clock.fixed(Instant.parse("2026-09-03T08:00:00Z"), ZoneOffset.UTC), + Duration.ofHours(1) + ); + StreamMessageId messageId = new StreamMessageId(15, 0); + Map task = Map.of( + "taskId", "task-ack-recovery", + "versionId", "42", + "skillPath", "/tmp/skillhub-scans/42", + "createdAtMillis", String.valueOf(Instant.parse("2026-09-03T06:00:00Z").toEpochMilli()), + "scannerType", ScannerType.SKILL_SCANNER.getValue() + ); + when(consumer.stream.ack("skillhub-scanners", messageId)) + .thenThrow(new IllegalStateException("redis unavailable")) + .thenReturn(1L); + + assertThatThrownBy(() -> consumer.handleMessage(messageId, task)) + .isInstanceOf(IllegalStateException.class) + .hasMessage("redis unavailable"); + consumer.handleMessage(messageId, task); + + assertThat(securityScanner.invocations).isEqualTo(1); + verify(consumer.stream).remove(messageId); } @Test @@ -355,6 +527,24 @@ class ScanTaskConsumerTest { verify(processingLock).unlock(); } + private StubSecurityScanner unavailableScanner() { + StubSecurityScanner scanner = new StubSecurityScanner(); + scanner.failure = new SecurityScanException( + "scanner timed out", new HttpClientException("request timed out", new java.util.concurrent.TimeoutException())); + return scanner; + } + + private SkillVersion scanningVersion(Long id) { + SkillVersion version = new SkillVersion(8L, "1.0.0", "publisher-1"); + try { + setField(version, "id", id); + } catch (Exception e) { + throw new AssertionError(e); + } + version.setStatus(SkillVersionStatus.SCANNING); + return version; + } + private void setField(Object target, String fieldName, Object value) throws Exception { Field field = target.getClass().getDeclaredField(fieldName); field.setAccessible(true); @@ -399,6 +589,35 @@ class ScanTaskConsumerTest { this.stream = mock(RStream.class); } + @SuppressWarnings("unchecked") + private TestableScanTaskConsumer(SecurityScanner securityScanner, + SecurityScanService securityScanService, + SkillVersionRepository skillVersionRepository, + ScanTaskProducer scanTaskProducer, + ObjectStorageService objectStorageService, + Clock clock, + Duration maxUnavailableAge) { + super( + redissonClient(availableProcessingLock()), + "skillhub:scan:requests", + "skillhub-scanners", + securityScanner, + securityScanService, + skillVersionRepository, + scanTaskProducer, + objectStorageService, + true, + Duration.ofMinutes(16), + 20, + Duration.ofSeconds(30), + 3, + maxUnavailableAge, + clock, + new MessageObservationSupport(ObservationRegistry.NOOP, new RequestIdAccessor()) + ); + this.stream = mock(RStream.class); + } + @SuppressWarnings("unchecked") private TestableScanTaskConsumer(SecurityScanner securityScanner, SecurityScanService securityScanService, @@ -459,9 +678,11 @@ class ScanTaskConsumerTest { private SecurityScanRequest lastRequest; private SecurityScanResponse response; private RuntimeException failure; + private int invocations; @Override public SecurityScanResponse scan(SecurityScanRequest request) { + invocations++; this.lastRequest = request; if (failure != null) { throw failure; @@ -484,6 +705,10 @@ class ScanTaskConsumerTest { private Long lastVersionId; private ScannerType lastScannerType; private SecurityScanResponse lastResponse; + private String failedTaskId; + private Long failedVersionId; + private String failedReason; + private boolean processed; private StubSecurityScanService() { super(null, null, task -> { @@ -491,11 +716,27 @@ class ScanTaskConsumerTest { } @Override - public void processScanResult(Long versionId, ScannerType scannerType, SecurityScanResponse response) { + public void processScanResult(String taskId, + Long versionId, + ScannerType scannerType, + SecurityScanResponse response) { this.lastVersionId = versionId; this.lastScannerType = scannerType; this.lastResponse = response; } + + @Override + public void processScanFailure(String taskId, Long versionId, ScannerType scannerType, String reason) { + this.failedTaskId = taskId; + this.failedVersionId = versionId; + this.failedReason = reason; + this.processed = true; + } + + @Override + public boolean isTaskAlreadyProcessed(String taskId) { + return processed && taskId.equals(failedTaskId); + } } private static final class InMemorySkillVersionRepository implements SkillVersionRepository { @@ -611,6 +852,18 @@ class ScanTaskConsumerTest { throw unsupported(); } + @Override + public List findBySubmittedByAndSkillIdAndSkillVersionOrderBySubmittedAtDescIdDesc( + String submittedBy, Long skillId, String skillVersion) { + throw unsupported(); + } + + @Override + public List findBySkillIdAndSkillVersionOrderBySubmittedAtDescIdDesc( + Long skillId, String skillVersion) { + throw unsupported(); + } + @Override public boolean existsByNamespaceId(Long namespaceId) { return false; @@ -621,6 +874,11 @@ class ScanTaskConsumerTest { throw unsupported(); } + @Override + public void deleteBySkillId(Long skillId) { + throw unsupported(); + } + @Override public void delete(ReviewTask reviewTask) { this.deletedTask = reviewTask; diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/SecurityConfig.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/SecurityConfig.java index e205fb7a..91942f56 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/SecurityConfig.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/config/SecurityConfig.java @@ -8,6 +8,7 @@ import com.iflytek.skillhub.auth.oauth.SkillHubOAuth2AuthorizationRequestResolve import com.iflytek.skillhub.auth.mock.MockAuthFilter; import com.iflytek.skillhub.auth.policy.RouteSecurityPolicyRegistry; import com.iflytek.skillhub.auth.session.ExpiredPublicSessionFilter; +import com.iflytek.skillhub.auth.session.CorruptSessionRemover; import com.iflytek.skillhub.auth.token.ApiTokenAuthenticationFilter; import com.iflytek.skillhub.auth.token.ApiTokenScopeFilter; import jakarta.servlet.http.Cookie; @@ -16,6 +17,7 @@ import jakarta.servlet.http.HttpServletResponse; import org.springframework.beans.factory.ObjectProvider; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; @@ -68,6 +70,8 @@ public class SecurityConfig { private final AccessDeniedHandler apiAccessDeniedHandler; private final ObjectProvider mockAuthFilterProvider; private final RouteSecurityPolicyRegistry routeSecurityPolicyRegistry; + private final CorruptSessionRemover corruptSessionRemover; + private final String sessionCookieName; public SecurityConfig(CustomOAuth2UserService customOAuth2UserService, CustomOidcUserService customOidcUserService, @@ -79,7 +83,9 @@ public class SecurityConfig { AuthenticationEntryPoint apiAuthenticationEntryPoint, AccessDeniedHandler apiAccessDeniedHandler, ObjectProvider mockAuthFilterProvider, - RouteSecurityPolicyRegistry routeSecurityPolicyRegistry) { + RouteSecurityPolicyRegistry routeSecurityPolicyRegistry, + ObjectProvider corruptSessionRemoverProvider, + @Value("${server.servlet.session.cookie.name:SESSION}") String sessionCookieName) { this.customOAuth2UserService = customOAuth2UserService; this.customOidcUserService = customOidcUserService; this.authorizationRequestResolver = authorizationRequestResolver; @@ -91,6 +97,10 @@ public class SecurityConfig { this.apiAccessDeniedHandler = apiAccessDeniedHandler; this.mockAuthFilterProvider = mockAuthFilterProvider; this.routeSecurityPolicyRegistry = routeSecurityPolicyRegistry; + this.corruptSessionRemover = corruptSessionRemoverProvider.getIfAvailable(() -> sessionId -> { + throw new IllegalStateException("Corrupt session recovery is not configured"); + }); + this.sessionCookieName = sessionCookieName; } /** @@ -161,9 +171,10 @@ public class SecurityConfig { response.sendRedirect(((contextPath == null) ? "" : contextPath) + "/"); }) .invalidateHttpSession(true) - .deleteCookies("SESSION") + .deleteCookies(sessionCookieName) ) - .addFilterBefore(new ExpiredPublicSessionFilter(routeSecurityPolicyRegistry), CsrfFilter.class) + .addFilterBefore(new ExpiredPublicSessionFilter( + routeSecurityPolicyRegistry, corruptSessionRemover, sessionCookieName), CsrfFilter.class) .addFilterBefore(apiTokenAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) .addFilterAfter(apiTokenScopeFilter, ApiTokenAuthenticationFilter.class); diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistry.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistry.java index 09ed21c7..23375ed9 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistry.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistry.java @@ -39,11 +39,19 @@ public class RouteSecurityPolicyRegistry { RouteAuthorizationPolicy.authenticated(HttpMethod.DELETE, "/api/v1/skills/*/star"), RouteAuthorizationPolicy.authenticated(HttpMethod.GET, "/api/v1/skills/*/rating"), RouteAuthorizationPolicy.authenticated(HttpMethod.PUT, "/api/v1/skills/*/rating"), + RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/v1/skills/*/reviews"), + RouteAuthorizationPolicy.authenticated(HttpMethod.GET, "/api/v1/skills/*/reviews/me"), + RouteAuthorizationPolicy.authenticated(HttpMethod.PUT, "/api/v1/skills/*/reviews/me"), + RouteAuthorizationPolicy.authenticated(HttpMethod.DELETE, "/api/v1/skills/*/reviews/me"), RouteAuthorizationPolicy.authenticated(HttpMethod.GET, "/api/web/skills/*/star"), RouteAuthorizationPolicy.authenticated(HttpMethod.PUT, "/api/web/skills/*/star"), RouteAuthorizationPolicy.authenticated(HttpMethod.DELETE, "/api/web/skills/*/star"), RouteAuthorizationPolicy.authenticated(HttpMethod.GET, "/api/web/skills/*/rating"), RouteAuthorizationPolicy.authenticated(HttpMethod.PUT, "/api/web/skills/*/rating"), + RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/web/skills/*/reviews"), + RouteAuthorizationPolicy.authenticated(HttpMethod.GET, "/api/web/skills/*/reviews/me"), + RouteAuthorizationPolicy.authenticated(HttpMethod.PUT, "/api/web/skills/*/reviews/me"), + RouteAuthorizationPolicy.authenticated(HttpMethod.DELETE, "/api/web/skills/*/reviews/me"), RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/v1/skills"), RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/v1/skills/*/*"), RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/v1/skills/*/*/versions"), @@ -112,9 +120,13 @@ public class RouteSecurityPolicyRegistry { ApiTokenPolicy.allow(HttpMethod.PUT, "/api/v1/skills/*/star"), ApiTokenPolicy.allow(HttpMethod.DELETE, "/api/v1/skills/*/star"), ApiTokenPolicy.allow(HttpMethod.PUT, "/api/v1/skills/*/rating"), + ApiTokenPolicy.allow(HttpMethod.PUT, "/api/v1/skills/*/reviews/me"), + ApiTokenPolicy.allow(HttpMethod.DELETE, "/api/v1/skills/*/reviews/me"), ApiTokenPolicy.allow(HttpMethod.PUT, "/api/web/skills/*/star"), ApiTokenPolicy.allow(HttpMethod.DELETE, "/api/web/skills/*/star"), ApiTokenPolicy.allow(HttpMethod.PUT, "/api/web/skills/*/rating"), + ApiTokenPolicy.allow(HttpMethod.PUT, "/api/web/skills/*/reviews/me"), + ApiTokenPolicy.allow(HttpMethod.DELETE, "/api/web/skills/*/reviews/me"), ApiTokenPolicy.allow(HttpMethod.GET, "/api/v1/namespaces"), ApiTokenPolicy.allow(HttpMethod.GET, "/api/v1/namespaces/*"), ApiTokenPolicy.allow(HttpMethod.GET, "/api/web/namespaces"), @@ -132,6 +144,7 @@ public class RouteSecurityPolicyRegistry { ApiTokenPolicy.require(HttpMethod.DELETE, "/api/v1/skills/*/*", "skill:delete"), ApiTokenPolicy.require(HttpMethod.POST, "/api/v1/skills", "skill:publish"), ApiTokenPolicy.require(HttpMethod.POST, "/api/v1/skills/*/publish", "skill:publish"), + ApiTokenPolicy.require(HttpMethod.POST, "/api/v1/skills/*/versions/*/security-audit/retry", "skill:publish"), ApiTokenPolicy.require(HttpMethod.POST, "/api/web/skills/*/publish", "skill:publish"), ApiTokenPolicy.require(HttpMethod.POST, "/api/v1/publish", "skill:publish"), ApiTokenPolicy.allow(HttpMethod.GET, "/api/cli/v1/auth/whoami"), diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/session/CorruptSessionRemover.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/session/CorruptSessionRemover.java new file mode 100644 index 00000000..3f9fd8b0 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/session/CorruptSessionRemover.java @@ -0,0 +1,11 @@ +package com.iflytek.skillhub.auth.session; + +/** + * Removes a session record that cannot be deserialized by the current application version. + * The input is the server-side session id already resolved by Spring Session. + */ +@FunctionalInterface +public interface CorruptSessionRemover { + + void remove(String sessionId); +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/session/ExpiredPublicSessionFilter.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/session/ExpiredPublicSessionFilter.java index 934cbe81..db1a4155 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/session/ExpiredPublicSessionFilter.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/session/ExpiredPublicSessionFilter.java @@ -9,7 +9,11 @@ import jakarta.servlet.http.HttpServletRequestWrapper; import jakarta.servlet.http.HttpServletResponse; import java.io.IOException; import java.util.Arrays; +import java.util.HashSet; import java.util.Set; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.data.redis.serializer.SerializationException; import org.springframework.web.filter.OncePerRequestFilter; /** @@ -17,12 +21,21 @@ import org.springframework.web.filter.OncePerRequestFilter; */ public final class ExpiredPublicSessionFilter extends OncePerRequestFilter { - private static final Set SESSION_COOKIES = Set.of("SESSION", "JSESSIONID"); + private static final Logger log = LoggerFactory.getLogger(ExpiredPublicSessionFilter.class); private final RouteSecurityPolicyRegistry routeSecurityPolicyRegistry; + private final CorruptSessionRemover corruptSessionRemover; + private final Set sessionCookieNames; - public ExpiredPublicSessionFilter(RouteSecurityPolicyRegistry routeSecurityPolicyRegistry) { + public ExpiredPublicSessionFilter(RouteSecurityPolicyRegistry routeSecurityPolicyRegistry, + CorruptSessionRemover corruptSessionRemover, + String sessionCookieName) { this.routeSecurityPolicyRegistry = routeSecurityPolicyRegistry; + this.corruptSessionRemover = corruptSessionRemover; + Set cookieNames = new HashSet<>(); + cookieNames.add(sessionCookieName); + cookieNames.add("JSESSIONID"); + this.sessionCookieNames = Set.copyOf(cookieNames); } @Override @@ -33,17 +46,55 @@ public final class ExpiredPublicSessionFilter extends OncePerRequestFilter { String requestPath = RouteSecurityPolicyRegistry.requestPath(request); boolean publicRoute = routeSecurityPolicyRegistry.accessLevel(request.getMethod(), requestPath) == RouteSecurityPolicyRegistry.AccessLevel.PERMIT_ALL; - if (publicRoute && request.getRequestedSessionId() != null && !request.isRequestedSessionIdValid()) { - filterChain.doFilter(new SessionlessRequest(request), response); + try { + String requestedSessionId = request.getRequestedSessionId(); + if (publicRoute && requestedSessionId != null && !request.isRequestedSessionIdValid()) { + filterChain.doFilter(new SessionlessRequest(request, sessionCookieNames), response); + return; + } + } catch (SerializationException error) { + // Spring Session resolves and caches the server-side session id before loading the + // Redis record. Re-read that cached id so custom cookie serializers and jvmRoute + // settings remain Spring Session's responsibility. + String corruptSessionId = request.getRequestedSessionId(); + if (corruptSessionId == null) { + throw error; + } + // Delete only the unreadable record. Redis connectivity failures from deleteById + // intentionally propagate instead of disguising a storage outage as logout. + corruptSessionRemover.remove(corruptSessionId); + expireSessionCookies(request, response); + log.warn("Removed an unreadable HTTP session; the client must authenticate again"); + filterChain.doFilter(new SessionlessRequest(request, sessionCookieNames), response); return; } filterChain.doFilter(request, response); } + private void expireSessionCookies(HttpServletRequest request, HttpServletResponse response) { + Cookie[] cookies = request.getCookies(); + if (cookies == null) { + return; + } + Arrays.stream(cookies) + .filter(cookie -> sessionCookieNames.contains(cookie.getName())) + .forEach(cookie -> { + Cookie expired = new Cookie(cookie.getName(), ""); + expired.setHttpOnly(true); + expired.setSecure(request.isSecure()); + expired.setPath(request.getContextPath().isBlank() ? "/" : request.getContextPath()); + expired.setMaxAge(0); + response.addCookie(expired); + }); + } + private static final class SessionlessRequest extends HttpServletRequestWrapper { - private SessionlessRequest(HttpServletRequest request) { + private final Set sessionCookieNames; + + private SessionlessRequest(HttpServletRequest request, Set sessionCookieNames) { super(request); + this.sessionCookieNames = sessionCookieNames; } @Override @@ -68,7 +119,7 @@ public final class ExpiredPublicSessionFilter extends OncePerRequestFilter { return null; } Cookie[] retained = Arrays.stream(cookies) - .filter(cookie -> !SESSION_COOKIES.contains(cookie.getName())) + .filter(cookie -> !sessionCookieNames.contains(cookie.getName())) .toArray(Cookie[]::new); return retained.length == 0 ? null : retained; } diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistryTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistryTest.java index 0967b746..11697808 100644 --- a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistryTest.java +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistryTest.java @@ -34,6 +34,26 @@ class RouteSecurityPolicyRegistryTest { registry.accessLevel("GET", "/api/v1/resolve/team/demo")); } + @Test + void reviewRoutesExposePublicListingButProtectCurrentUserMutations() { + assertEquals(RouteSecurityPolicyRegistry.AccessLevel.PERMIT_ALL, + registry.accessLevel("GET", "/api/v1/skills/10/reviews")); + assertEquals(RouteSecurityPolicyRegistry.AccessLevel.AUTHENTICATED, + registry.accessLevel("GET", "/api/v1/skills/10/reviews/me")); + assertEquals(RouteSecurityPolicyRegistry.AccessLevel.AUTHENTICATED, + registry.accessLevel("PUT", "/api/v1/skills/10/reviews/me")); + assertEquals(RouteSecurityPolicyRegistry.AccessLevel.AUTHENTICATED, + registry.accessLevel("DELETE", "/api/v1/skills/10/reviews/me")); + } + + @Test + void apiTokenPolicyAllowsCurrentUserReviewMutations() { + assertTrue(registry.authorizeApiToken( + "PUT", "/api/v1/skills/10/reviews/me", Set.of()).allowed()); + assertTrue(registry.authorizeApiToken( + "DELETE", "/api/v1/skills/10/reviews/me", Set.of()).allowed()); + } + @Test void authorizeApiToken_requiresPublishScopeForPublishEndpoints() { var denied = registry.authorizeApiToken("POST", "/api/web/skills/global/publish", Set.of("skill:read")); @@ -44,6 +64,18 @@ class RouteSecurityPolicyRegistryTest { assertTrue(allowed.allowed()); } + @Test + void authorizeApiToken_requiresPublishScopeForSecurityScanRetry() { + var denied = registry.authorizeApiToken( + "POST", "/api/v1/skills/8/versions/42/security-audit/retry", Set.of("skill:read")); + var allowed = registry.authorizeApiToken( + "POST", "/api/v1/skills/8/versions/42/security-audit/retry", Set.of("skill:publish")); + + assertFalse(denied.allowed()); + assertEquals("skill:publish", denied.requiredScope()); + assertTrue(allowed.allowed()); + } + @Test void authorizeApiToken_requiresDeleteScopeForHardDeleteEndpoint() { var denied = registry.authorizeApiToken("DELETE", "/api/v1/skills/global/demo-skill", Set.of("skill:publish")); diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/session/ExpiredPublicSessionFilterTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/session/ExpiredPublicSessionFilterTest.java index 9f7d3eba..beba489d 100644 --- a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/session/ExpiredPublicSessionFilterTest.java +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/session/ExpiredPublicSessionFilterTest.java @@ -2,10 +2,13 @@ package com.iflytek.skillhub.auth.session; import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; import com.iflytek.skillhub.auth.policy.RouteSecurityPolicyRegistry; import jakarta.servlet.FilterChain; @@ -13,15 +16,19 @@ import jakarta.servlet.ServletRequest; import jakarta.servlet.ServletResponse; import jakarta.servlet.http.Cookie; import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletRequestWrapper; +import java.util.concurrent.atomic.AtomicInteger; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; import org.springframework.mock.web.MockHttpServletRequest; import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.data.redis.serializer.SerializationException; class ExpiredPublicSessionFilterTest { - private final ExpiredPublicSessionFilter filter = - new ExpiredPublicSessionFilter(new RouteSecurityPolicyRegistry()); + private final CorruptSessionRemover corruptSessionRemover = mock(CorruptSessionRemover.class); + private final ExpiredPublicSessionFilter filter = new ExpiredPublicSessionFilter( + new RouteSecurityPolicyRegistry(), corruptSessionRemover, "SESSION"); @Test void expiredSessionOnPublicRoute_shouldBeHiddenFromDownstreamSecurityFilters() throws Exception { @@ -74,6 +81,61 @@ class ExpiredPublicSessionFilterTest { assertNull(capturedRequest(chain).getRequestedSessionId()); } + @Test + void unreadableSession_shouldBeDeletedAndTreatedAsLoggedOut() throws Exception { + MockHttpServletRequest delegate = new MockHttpServletRequest(); + delegate.setMethod("GET"); + delegate.setRequestURI("/api/v1/auth/methods"); + delegate.setCookies(new Cookie("SESSION", "corrupt-session")); + AtomicInteger calls = new AtomicInteger(); + HttpServletRequest request = new HttpServletRequestWrapper(delegate) { + @Override + public String getRequestedSessionId() { + if (calls.getAndIncrement() == 0) { + throw new SerializationException("incompatible session data"); + } + return "resolved-session-id"; + } + }; + MockHttpServletResponse response = new MockHttpServletResponse(); + FilterChain chain = mock(FilterChain.class); + + filter.doFilter(request, response, chain); + + verify(corruptSessionRemover).remove("resolved-session-id"); + assertNull(capturedRequest(chain).getRequestedSessionId()); + assertNotNull(response.getCookie("SESSION")); + assertEquals(0, response.getCookie("SESSION").getMaxAge()); + } + + @Test + void unreadableSession_shouldNotHideRedisDeleteFailure() { + MockHttpServletRequest delegate = new MockHttpServletRequest(); + delegate.setMethod("GET"); + delegate.setRequestURI("/api/v1/auth/methods"); + delegate.setCookies(new Cookie("SESSION", "encoded-cookie")); + AtomicInteger calls = new AtomicInteger(); + HttpServletRequest request = new HttpServletRequestWrapper(delegate) { + @Override + public String getRequestedSessionId() { + if (calls.getAndIncrement() == 0) { + throw new SerializationException("incompatible session data"); + } + return "resolved-session-id"; + } + }; + RuntimeException redisFailure = new RuntimeException("redis unavailable"); + org.mockito.Mockito.doThrow(redisFailure) + .when(corruptSessionRemover).remove("resolved-session-id"); + FilterChain chain = mock(FilterChain.class); + + RuntimeException actual = assertThrows(RuntimeException.class, + () -> filter.doFilter(request, new MockHttpServletResponse(), chain)); + + assertSame(redisFailure, actual); + verifyNoInteractions(chain); + } + private static MockHttpServletRequest expiredSessionRequest(String method, String path) { MockHttpServletRequest request = new MockHttpServletRequest(); request.setMethod(method); diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewService.java index 18275f3e..98b19649 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewService.java @@ -111,7 +111,8 @@ public class ReviewService { skillVersion.setStatus(SkillVersionStatus.PENDING_REVIEW); skillVersionRepository.save(skillVersion); - ReviewTask task = new ReviewTask(skillVersionId, skill.getNamespaceId(), userId); + ReviewTask task = new ReviewTask( + skillVersionId, skill.getId(), skill.getNamespaceId(), skillVersion.getVersion(), userId); try { ReviewTask saved = reviewTaskRepository.save(task); eventPublisher.publishEvent(new ReviewSubmittedEvent( @@ -153,7 +154,8 @@ public class ReviewService { skillVersion.setStatus(SkillVersionStatus.PENDING_REVIEW); skillVersionRepository.save(skillVersion); - ReviewTask task = new ReviewTask(skillVersionId, skill.getNamespaceId(), userId); + ReviewTask task = new ReviewTask( + skillVersionId, skill.getId(), skill.getNamespaceId(), skillVersion.getVersion(), userId); try { ReviewTask saved = reviewTaskRepository.save(task); eventPublisher.publishEvent(new ReviewSubmittedEvent( diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewTask.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewTask.java index 4ccd6786..9b908939 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewTask.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewTask.java @@ -11,9 +11,15 @@ public class ReviewTask { @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; - @Column(name = "skill_version_id", nullable = false) + @Column(name = "skill_version_id") private Long skillVersionId; + @Column(name = "skill_id", nullable = false) + private Long skillId; + + @Column(name = "skill_version", nullable = false, length = 64) + private String skillVersion; + @Column(name = "namespace_id", nullable = false) private Long namespaceId; @@ -49,10 +55,23 @@ public class ReviewTask { this.submittedBy = submittedBy; } + public ReviewTask(Long skillVersionId, Long skillId, Long namespaceId, + String skillVersion, String submittedBy) { + this.skillVersionId = skillVersionId; + this.skillId = skillId; + this.namespaceId = namespaceId; + this.skillVersion = skillVersion; + this.submittedBy = submittedBy; + } + public Long getId() { return id; } public Long getSkillVersionId() { return skillVersionId; } + public Long getSkillId() { return skillId; } + + public String getSkillVersion() { return skillVersion; } + public Long getNamespaceId() { return namespaceId; } public ReviewTaskStatus getStatus() { return status; } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewTaskRepository.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewTaskRepository.java index 25c7d9f0..3f0faccb 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewTaskRepository.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewTaskRepository.java @@ -3,6 +3,7 @@ package com.iflytek.skillhub.domain.review; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import java.util.Collection; +import java.util.List; import java.util.Optional; /** @@ -15,8 +16,13 @@ public interface ReviewTaskRepository { Page findByStatus(ReviewTaskStatus status, Pageable pageable); Page findByNamespaceIdAndStatus(Long namespaceId, ReviewTaskStatus status, Pageable pageable); Page findBySubmittedByAndStatus(String submittedBy, ReviewTaskStatus status, Pageable pageable); + List findBySubmittedByAndSkillIdAndSkillVersionOrderBySubmittedAtDescIdDesc( + String submittedBy, Long skillId, String skillVersion); + List findBySkillIdAndSkillVersionOrderBySubmittedAtDescIdDesc( + Long skillId, String skillVersion); boolean existsByNamespaceId(Long namespaceId); void deleteBySkillVersionIdIn(Collection skillVersionIds); + void deleteBySkillId(Long skillId); void delete(ReviewTask reviewTask); int updateStatusWithVersion(Long id, ReviewTaskStatus status, String reviewedBy, String reviewComment, Integer expectedVersion); diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/security/SecurityAudit.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/security/SecurityAudit.java index 0b7844de..4c8c5520 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/security/SecurityAudit.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/security/SecurityAudit.java @@ -56,6 +56,9 @@ public class SecurityAudit { @Column(name = "scan_duration_seconds") private Double scanDurationSeconds; + @Column(name = "failure_reason", length = 1000) + private String failureReason; + @Column(name = "scanned_at") private Instant scannedAt; @@ -131,6 +134,10 @@ public class SecurityAudit { return scanDurationSeconds; } + public String getFailureReason() { + return failureReason; + } + public Instant getScannedAt() { return scannedAt; } @@ -171,6 +178,18 @@ public class SecurityAudit { this.scannedAt = scannedAt; } + public void markFailed(Instant failedAt, String reason) { + this.failureReason = truncate(reason, 1000); + this.scannedAt = failedAt; + } + + private String truncate(String value, int maxLength) { + if (value == null || value.length() <= maxLength) { + return value; + } + return value.substring(0, maxLength); + } + public Instant getDeletedAt() { return deletedAt; } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/security/SecurityAuditRepository.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/security/SecurityAuditRepository.java index 9bde6ea6..cf655430 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/security/SecurityAuditRepository.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/security/SecurityAuditRepository.java @@ -12,6 +12,8 @@ public interface SecurityAuditRepository { Optional findByScanId(String scanId); + Optional findByTaskId(String taskId); + boolean existsByTaskIdAndScannedAtIsNotNull(String taskId); boolean existsBySkillVersionId(Long skillVersionId); diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/security/SecurityScanService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/security/SecurityScanService.java index df12ee8e..6c96c3a7 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/security/SecurityScanService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/security/SecurityScanService.java @@ -69,6 +69,27 @@ public class SecurityScanService { return enabled; } + @Transactional + public ScanTask retryStoredBundleScan(SkillVersion version, String bundleKey, String publisherId) { + if (!enabled) { + throw new IllegalStateException("Security scanner is disabled"); + } + if (version.getStatus() != SkillVersionStatus.SCAN_FAILED) { + throw new IllegalStateException("Only SCAN_FAILED versions can be retried"); + } + ScanTask scanTask = new ScanTask( + UUID.randomUUID().toString(), + version.getId(), + null, + bundleKey, + publisherId, + System.currentTimeMillis(), + Map.of("scannerType", ScannerType.SKILL_SCANNER.getValue()) + ); + persistScanAttempt(version, scanTask); + return scanTask; + } + @Transactional public void triggerScan(Long versionId, List entries, String publisherId) { if (!enabled) { @@ -87,7 +108,6 @@ public class SecurityScanService { } else { packagePath = saveTempDirectory(versionId, entries).toString(); } - // Always create a new audit record — supports multiple rounds per version final ScanTask scanTask = new ScanTask( UUID.randomUUID().toString(), versionId, @@ -97,7 +117,12 @@ public class SecurityScanService { System.currentTimeMillis(), Map.of("scannerType", ScannerType.SKILL_SCANNER.getValue()) ); - auditRepository.save(new SecurityAudit(versionId, ScannerType.SKILL_SCANNER, scanTask.taskId())); + persistScanAttempt(version, scanTask); + } + + private void persistScanAttempt(SkillVersion version, ScanTask scanTask) { + // A new record preserves prior scan history while identifying this attempt independently. + auditRepository.save(new SecurityAudit(version.getId(), ScannerType.SKILL_SCANNER, scanTask.taskId())); if (scanTaskOutboxRepository != null) { scanTaskOutboxRepository.save(new ScanTaskOutbox(scanTask)); } else { @@ -116,10 +141,42 @@ public class SecurityScanService { } @Transactional - public void processScanResult(Long versionId, ScannerType scannerType, SecurityScanResponse response) { - SecurityAudit audit = auditRepository.findLatestActiveByVersionIdAndScannerType(versionId, scannerType) + public void processScanFailure(String taskId, Long versionId, ScannerType scannerType, String reason) { + SecurityAudit audit = auditRepository.findByTaskId(taskId) + .filter(candidate -> candidate.getSkillVersionId().equals(versionId)) + .filter(candidate -> candidate.getScannerType() == scannerType) + .orElseThrow(() -> new IllegalStateException("SecurityAudit not found for taskId=" + taskId)); + if (audit.getScannedAt() != null) { + return; + } + audit.markFailed(Instant.now(Clock.systemUTC()), reason); + auditRepository.save(audit); + + boolean currentAttempt = auditRepository + .findLatestActiveByVersionIdAndScannerType(versionId, scannerType) + .map(latest -> taskId.equals(latest.getTaskId())) + .orElse(false); + if (!currentAttempt) { + return; + } + skillVersionRepository.findById(versionId) + .filter(version -> version.getStatus() == SkillVersionStatus.SCANNING) + .ifPresent(version -> { + version.setStatus(SkillVersionStatus.SCAN_FAILED); + skillVersionRepository.save(version); + }); + } + + @Transactional + public void processScanResult(String taskId, + Long versionId, + ScannerType scannerType, + SecurityScanResponse response) { + SecurityAudit audit = auditRepository.findByTaskId(taskId) + .filter(candidate -> candidate.getSkillVersionId().equals(versionId)) + .filter(candidate -> candidate.getScannerType() == scannerType) .orElseThrow(() -> new IllegalStateException( - "SecurityAudit not found for versionId=" + versionId + ", scannerType=" + scannerType)); + "SecurityAudit not found for taskId=" + taskId)); SkillVersion version = skillVersionRepository.findById(versionId) .orElseThrow(() -> new IllegalStateException("SkillVersion not found: " + versionId)); @@ -133,15 +190,21 @@ public class SecurityScanService { audit.setScannedAt(Instant.now(Clock.systemUTC())); auditRepository.save(audit); - // Only transition from SCANNING — leave PUBLISHED/REJECTED/YANKED untouched - if (version.getStatus() == SkillVersionStatus.SCANNING) { + boolean currentAttempt = auditRepository + .findLatestActiveByVersionIdAndScannerType(versionId, scannerType) + .map(latest -> taskId.equals(latest.getTaskId())) + .orElse(false); + // A late result is retained on its own audit round but cannot complete a newer attempt. + if (currentAttempt && version.getStatus() == SkillVersionStatus.SCANNING) { if (version.getRequestedVisibility() == SkillVisibility.PRIVATE) { version.setStatus(SkillVersionStatus.UPLOADED); } else { version.setStatus(SkillVersionStatus.PENDING_REVIEW); } } - skillVersionRepository.save(version); + if (currentAttempt) { + skillVersionRepository.save(version); + } } private Path saveTempDirectory(Long versionId, List entries) { diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/shared/exception/DomainConflictException.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/shared/exception/DomainConflictException.java new file mode 100644 index 00000000..085c593a --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/shared/exception/DomainConflictException.java @@ -0,0 +1,16 @@ +package com.iflytek.skillhub.domain.shared.exception; + +/** + * Domain exception used when a concurrent request prevents a safe state change. + */ +public class DomainConflictException extends LocalizedDomainException { + + public DomainConflictException(String messageCode, Object... messageArgs) { + super(messageCode, messageArgs); + } + + @Override + public int statusCode() { + return 409; + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillVersionRepository.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillVersionRepository.java index b99cc50f..91f103c7 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillVersionRepository.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillVersionRepository.java @@ -8,6 +8,14 @@ import java.util.Optional; */ public interface SkillVersionRepository { Optional findById(Long id); + default Optional findByIdForUpdate(Long id) { + throw new UnsupportedOperationException("This repository does not provide row locking"); + } + default Optional findStatusByIdAndSkillId(Long id, Long skillId) { + return findById(id) + .filter(version -> version.getSkillId().equals(skillId)) + .map(SkillVersion::getStatus); + } List findByIdIn(List ids); List findBySkillIdIn(List skillIds); List findBySkillIdInAndStatus(List skillIds, SkillVersionStatus status); diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillHardDeleteService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillHardDeleteService.java index dbfd9206..e548a608 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillHardDeleteService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillHardDeleteService.java @@ -107,9 +107,8 @@ public class SkillHardDeleteService { skillRepository.save(skill); skillRepository.flush(); - if (!versionIds.isEmpty()) { - reviewTaskRepository.deleteBySkillVersionIdIn(versionIds); - } + // Also removes detached historical attempts whose replaced skill version no longer exists. + reviewTaskRepository.deleteBySkillId(skill.getId()); promotionRequestRepository.deleteBySourceSkillIdOrTargetSkillId(skill.getId(), skill.getId()); skillTagRepository.deleteBySkillId(skill.getId()); skillStarRepository.deleteBySkillId(skill.getId()); diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillPublishService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillPublishService.java index bc6e0b0a..8313534f 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillPublishService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillPublishService.java @@ -561,7 +561,8 @@ public class SkillPublishService { // Create review task for PUBLIC/NAMESPACE_ONLY (not PRIVATE) if (!autoPublish && visibility != SkillVisibility.PRIVATE) { - ReviewTask reviewTask = new ReviewTask(version.getId(), namespace.getId(), publisherId); + ReviewTask reviewTask = new ReviewTask( + version.getId(), skill.getId(), namespace.getId(), version.getVersion(), publisherId); ReviewTask savedReviewTask = reviewTaskRepository.save(reviewTask); eventPublisher.publishEvent(new ReviewSubmittedEvent( savedReviewTask.getId(), @@ -608,10 +609,11 @@ public class SkillPublishService { skillRepository.flush(); } - // Every review task referencing this version has to go, not just a PENDING one: - // a rejected version still owns a REJECTED task whose foreign key blocks the - // skill_version delete below, which surfaces to the caller as an HTTP 500. - reviewTaskRepository.deleteBySkillVersionIdIn(List.of(version.getId())); + // A replaceable version may still have one obsolete pending task, but settled attempts are + // durable governance history. The database detaches those settled attempts from the + // replaced version while retaining their skill/version snapshot. + reviewTaskRepository.findBySkillVersionIdAndStatus(version.getId(), ReviewTaskStatus.PENDING) + .ifPresent(reviewTaskRepository::delete); List files = skillFileRepository.findByVersionId(version.getId()); List storageKeys = new ArrayList<>(); diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillQueryService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillQueryService.java index c7a86cd7..d115e485 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillQueryService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillQueryService.java @@ -242,7 +242,8 @@ public class SkillQueryService { skill.getUpdatedAt(), canManageRestrictedSkill(skill, currentUserId, userNsRoles), canSubmitPromotion(namespace, skill, publishedVersion, currentUserId, userNsRoles), - headlineVersion == null || "PUBLISHED".equals(headlineVersion.status()), + skill.getStatus() == SkillStatus.ACTIVE + && (headlineVersion == null || "PUBLISHED".equals(headlineVersion.status())), currentUserId == null || !Objects.equals(skill.getOwnerId(), currentUserId), headlineVersion, publishedVersion, diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillReviewSubmitService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillReviewSubmitService.java index 0b2e396b..348693a1 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillReviewSubmitService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillReviewSubmitService.java @@ -95,7 +95,8 @@ public class SkillReviewSubmitService { skillVersionRepository.save(version); // Create review task - ReviewTask reviewTask = new ReviewTask(versionId, skill.getNamespaceId(), actorUserId); + ReviewTask reviewTask = new ReviewTask( + versionId, skill.getId(), skill.getNamespaceId(), version.getVersion(), actorUserId); reviewTaskRepository.save(reviewTask); } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/social/SkillRating.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/social/SkillRating.java index b38850ca..0448ea47 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/social/SkillRating.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/social/SkillRating.java @@ -9,9 +9,15 @@ import java.time.Instant; @Table(name = "skill_rating", uniqueConstraints = @UniqueConstraint(columnNames = {"skill_id", "user_id"})) public class SkillRating { + private static final int MAX_REVIEW_LENGTH = 2000; + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; + @Version + @Column(name = "lock_version", nullable = false) + private Long lockVersion; + @Column(name = "skill_id", nullable = false) private Long skillId; @@ -21,6 +27,22 @@ public class SkillRating { @Column(nullable = false) private Short score; + @Column(name = "review_text", length = MAX_REVIEW_LENGTH) + private String reviewText; + + @Enumerated(EnumType.STRING) + @Column(name = "review_status", nullable = false, length = 16) + private SkillReviewStatus reviewStatus = SkillReviewStatus.VISIBLE; + + @Column(name = "moderated_by", length = 128) + private String moderatedBy; + + @Column(name = "moderated_at") + private Instant moderatedAt; + + @Column(name = "moderation_reason", length = 500) + private String moderationReason; + @Column(name = "created_at", nullable = false) private Instant createdAt; @@ -37,11 +59,77 @@ public class SkillRating { } public void updateScore(short newScore) { - if (newScore < 1 || newScore > 5) throw new DomainBadRequestException("error.rating.score.invalid"); + validateScore(newScore); this.score = newScore; this.updatedAt = Instant.now(Clock.systemUTC()); } + public void updateReview(short newScore, String newReviewText) { + validateScore(newScore); + this.score = newScore; + this.reviewText = normalizeReviewText(newReviewText); + this.updatedAt = Instant.now(Clock.systemUTC()); + } + + public void clearReview() { + this.reviewText = null; + this.updatedAt = Instant.now(Clock.systemUTC()); + } + + public void hideReview(String moderatorId, String reason) { + ensureReviewExists(); + this.reviewStatus = SkillReviewStatus.HIDDEN; + this.moderatedBy = moderatorId; + this.moderatedAt = Instant.now(Clock.systemUTC()); + this.moderationReason = normalizeReason(reason); + } + + public void restoreReview(String moderatorId) { + ensureReviewExists(); + this.reviewStatus = SkillReviewStatus.VISIBLE; + this.moderatedBy = moderatorId; + this.moderatedAt = Instant.now(Clock.systemUTC()); + this.moderationReason = null; + } + + public boolean hasReview() { + return reviewText != null && !reviewText.isBlank(); + } + + private static void validateScore(short value) { + if (value < 1 || value > 5) { + throw new DomainBadRequestException("error.rating.score.invalid"); + } + } + + private static String normalizeReviewText(String value) { + if (value == null || value.isBlank()) { + throw new DomainBadRequestException("error.skillReview.text.required"); + } + String normalized = value.trim(); + if (normalized.length() > MAX_REVIEW_LENGTH) { + throw new DomainBadRequestException("error.skillReview.text.tooLong", MAX_REVIEW_LENGTH); + } + return normalized; + } + + private static String normalizeReason(String value) { + if (value == null || value.isBlank()) { + return null; + } + String normalized = value.trim(); + if (normalized.length() > 500) { + throw new DomainBadRequestException("error.skillReview.reason.tooLong", 500); + } + return normalized; + } + + private void ensureReviewExists() { + if (!hasReview()) { + throw new DomainBadRequestException("error.skillReview.notFound"); + } + } + @PrePersist void prePersist() { this.createdAt = Instant.now(Clock.systemUTC()); @@ -55,9 +143,15 @@ public class SkillRating { // getters public Long getId() { return id; } + public Long getLockVersion() { return lockVersion; } public Long getSkillId() { return skillId; } public String getUserId() { return userId; } public Short getScore() { return score; } + public String getReviewText() { return reviewText; } + public SkillReviewStatus getReviewStatus() { return reviewStatus; } + public String getModeratedBy() { return moderatedBy; } + public Instant getModeratedAt() { return moderatedAt; } + public String getModerationReason() { return moderationReason; } public Instant getCreatedAt() { return createdAt; } public Instant getUpdatedAt() { return updatedAt; } } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/social/SkillRatingRepository.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/social/SkillRatingRepository.java index f0a8a245..a608cfe6 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/social/SkillRatingRepository.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/social/SkillRatingRepository.java @@ -1,13 +1,19 @@ package com.iflytek.skillhub.domain.social; import java.util.Optional; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; /** * Domain repository contract for per-user ratings and rating aggregates on one skill. */ public interface SkillRatingRepository { SkillRating save(SkillRating rating); + void flush(); + Optional findById(Long id); Optional findBySkillIdAndUserId(Long skillId, String userId); + Page findVisibleReviewsBySkillId(Long skillId, Pageable pageable); + Page findReviewsBySkillId(Long skillId, Pageable pageable); double averageScoreBySkillId(Long skillId); int countBySkillId(Long skillId); void deleteBySkillId(Long skillId); diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/social/SkillRatingService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/social/SkillRatingService.java index 95e67b0c..879b7eac 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/social/SkillRatingService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/social/SkillRatingService.java @@ -1,10 +1,12 @@ package com.iflytek.skillhub.domain.social; import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; +import com.iflytek.skillhub.domain.shared.exception.DomainConflictException; import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException; import com.iflytek.skillhub.domain.skill.SkillRepository; import com.iflytek.skillhub.domain.social.event.SkillRatedEvent; import org.springframework.context.ApplicationEventPublisher; +import org.springframework.dao.DataIntegrityViolationException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -44,12 +46,76 @@ public class SkillRatingService { eventPublisher.publishEvent(new SkillRatedEvent(skillId, userId, score)); } + @Transactional + public SkillRating upsertReview(Long skillId, String userId, short score, String reviewText) { + ensureSkillExists(skillId); + SkillRating rating = ratingRepository.findBySkillIdAndUserId(skillId, userId) + .orElseGet(() -> new SkillRating(skillId, userId, score)); + rating.updateReview(score, reviewText); + SkillRating saved; + try { + saved = ratingRepository.save(rating); + ratingRepository.flush(); + } catch (DataIntegrityViolationException exception) { + throw new DomainConflictException("error.request.conflict"); + } + eventPublisher.publishEvent(new SkillRatedEvent(skillId, userId, score)); + return saved; + } + + @Transactional + public SkillRating clearReview(Long skillId, String userId) { + ensureSkillExists(skillId); + SkillRating rating = ratingRepository.findBySkillIdAndUserId(skillId, userId) + .filter(SkillRating::hasReview) + .orElseThrow(() -> new DomainNotFoundException("error.skillReview.notFound")); + rating.clearReview(); + SkillRating saved = ratingRepository.save(rating); + ratingRepository.flush(); + return saved; + } + + @Transactional + public SkillRating hideReview(Long reviewId, String moderatorId, String reason) { + SkillRating rating = findReview(reviewId); + rating.hideReview(moderatorId, reason); + SkillRating saved = ratingRepository.save(rating); + ratingRepository.flush(); + return saved; + } + + @Transactional + public SkillRating restoreReview(Long reviewId, String moderatorId) { + SkillRating rating = findReview(reviewId); + rating.restoreReview(moderatorId); + SkillRating saved = ratingRepository.save(rating); + ratingRepository.flush(); + return saved; + } + public Optional getUserRating(Long skillId, String userId) { ensureSkillExists(skillId); return ratingRepository.findBySkillIdAndUserId(skillId, userId) .map(SkillRating::getScore); } + public Optional getUserReview(Long skillId, String userId) { + ensureSkillExists(skillId); + return ratingRepository.findBySkillIdAndUserId(skillId, userId) + .filter(SkillRating::hasReview); + } + + public Optional getUserFeedback(Long skillId, String userId) { + ensureSkillExists(skillId); + return ratingRepository.findBySkillIdAndUserId(skillId, userId); + } + + private SkillRating findReview(Long reviewId) { + return ratingRepository.findById(reviewId) + .filter(SkillRating::hasReview) + .orElseThrow(() -> new DomainNotFoundException("error.skillReview.notFound")); + } + private void ensureSkillExists(Long skillId) { if (skillRepository.findById(skillId).isEmpty()) { throw new DomainNotFoundException("skill.not_found", skillId); diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/social/SkillReviewStatus.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/social/SkillReviewStatus.java new file mode 100644 index 00000000..f0b9e631 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/social/SkillReviewStatus.java @@ -0,0 +1,7 @@ +package com.iflytek.skillhub.domain.social; + +/** Visibility state for the optional review text attached to a skill rating. */ +public enum SkillReviewStatus { + VISIBLE, + HIDDEN +} diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/security/SecurityScanServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/security/SecurityScanServiceTest.java index 4150c57d..3292e499 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/security/SecurityScanServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/security/SecurityScanServiceTest.java @@ -136,6 +136,50 @@ class SecurityScanServiceTest { assertThat(task.bundleKey()).isEqualTo("packages/8/42/bundle.zip"); } + @Test + void retryStoredBundleScan_createsFreshAuditAndDurableOutbox() throws Exception { + ScanTaskOutboxRepository outboxRepository = org.mockito.Mockito.mock(ScanTaskOutboxRepository.class); + service = new SecurityScanService( + auditRepository, + skillVersionRepository, + scanTaskProducer, + new ObjectMapper(), + "local", + true, + outboxRepository + ); + SkillVersion version = new SkillVersion(8L, "1.0.0", "owner-1"); + setId(version, 42L); + version.setStatus(SkillVersionStatus.SCAN_FAILED); + + ScanTask task = service.retryStoredBundleScan(version, "packages/8/42/bundle.zip", "owner-1"); + + ArgumentCaptor auditCaptor = ArgumentCaptor.forClass(SecurityAudit.class); + ArgumentCaptor outboxCaptor = ArgumentCaptor.forClass(ScanTaskOutbox.class); + verify(auditRepository).save(auditCaptor.capture()); + verify(outboxRepository).save(outboxCaptor.capture()); + verify(scanTaskProducer, never()).publishScanTask(any()); + verify(skillVersionRepository).save(version); + assertThat(auditCaptor.getValue().getTaskId()).isEqualTo(task.taskId()); + assertThat(outboxCaptor.getValue().toScanTask()).isEqualTo(task); + assertThat(task.bundleKey()).isEqualTo("packages/8/42/bundle.zip"); + assertThat(task.metadata()).containsEntry("scannerType", "skill-scanner"); + assertThat(version.getStatus()).isEqualTo(SkillVersionStatus.SCANNING); + } + + @Test + void retryStoredBundleScan_rejectsNonFailedVersion() throws Exception { + SkillVersion version = new SkillVersion(8L, "1.0.0", "owner-1"); + setId(version, 42L); + version.setStatus(SkillVersionStatus.SCANNING); + + assertThatThrownBy(() -> service.retryStoredBundleScan(version, "bundle.zip", "owner-1")) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("SCAN_FAILED"); + + verify(auditRepository, never()).save(any()); + } + @Test void triggerScan_defersTaskPublishingUntilTransactionCommit() throws Exception { SkillVersion version = new SkillVersion(8L, "1.0.0", "publisher-1"); @@ -230,10 +274,11 @@ class SecurityScanServiceTest { @Test void processScanResult_updatesAuditAndMovesVersionToPendingReview() { - SecurityAudit audit = new SecurityAudit(42L, ScannerType.SKILL_SCANNER); + SecurityAudit audit = new SecurityAudit(42L, ScannerType.SKILL_SCANNER, "task-current"); SkillVersion version = new SkillVersion(8L, "1.0.0", "publisher-1"); version.setStatus(SkillVersionStatus.SCANNING); + given(auditRepository.findByTaskId("task-current")).willReturn(Optional.of(audit)); given(auditRepository.findLatestActiveByVersionIdAndScannerType(42L, ScannerType.SKILL_SCANNER)) .willReturn(Optional.of(audit)); given(skillVersionRepository.findById(42L)).willReturn(Optional.of(version)); @@ -256,7 +301,7 @@ class SecurityScanServiceTest { 1.25 ); - service.processScanResult(42L, ScannerType.SKILL_SCANNER, response); + service.processScanResult("task-current", 42L, ScannerType.SKILL_SCANNER, response); assertThat(audit.getScanId()).isEqualTo("scan-123"); assertThat(audit.getVerdict()).isEqualTo(SecurityVerdict.DANGEROUS); @@ -292,12 +337,52 @@ class SecurityScanServiceTest { assertThat(version.getStatus()).isEqualTo(SkillVersionStatus.PUBLISHED); } + @Test + void processScanFailure_marksExactCurrentAttemptAndVersionFailed() throws Exception { + SecurityAudit audit = new SecurityAudit(42L, ScannerType.SKILL_SCANNER, "task-current"); + SkillVersion version = new SkillVersion(8L, "1.0.0", "publisher-1"); + setId(version, 42L); + version.setStatus(SkillVersionStatus.SCANNING); + given(auditRepository.findByTaskId("task-current")).willReturn(Optional.of(audit)); + given(auditRepository.findLatestActiveByVersionIdAndScannerType(42L, ScannerType.SKILL_SCANNER)) + .willReturn(Optional.of(audit)); + given(skillVersionRepository.findById(42L)).willReturn(Optional.of(version)); + + service.processScanFailure("task-current", 42L, ScannerType.SKILL_SCANNER, "scanner unavailable"); + + assertThat(audit.getScannedAt()).isNotNull(); + assertThat(audit.getFailureReason()).isEqualTo("scanner unavailable"); + assertThat(version.getStatus()).isEqualTo(SkillVersionStatus.SCAN_FAILED); + verify(auditRepository).save(audit); + verify(skillVersionRepository).save(version); + } + + @Test + void processScanFailure_forStaleAttemptDoesNotFailCurrentVersion() throws Exception { + SecurityAudit stale = new SecurityAudit(42L, ScannerType.SKILL_SCANNER, "task-stale"); + SecurityAudit current = new SecurityAudit(42L, ScannerType.SKILL_SCANNER, "task-current"); + SkillVersion version = new SkillVersion(8L, "1.0.0", "publisher-1"); + setId(version, 42L); + version.setStatus(SkillVersionStatus.SCANNING); + given(auditRepository.findByTaskId("task-stale")).willReturn(Optional.of(stale)); + given(auditRepository.findLatestActiveByVersionIdAndScannerType(42L, ScannerType.SKILL_SCANNER)) + .willReturn(Optional.of(current)); + + service.processScanFailure("task-stale", 42L, ScannerType.SKILL_SCANNER, "stale failure"); + + assertThat(stale.getScannedAt()).isNotNull(); + assertThat(stale.getFailureReason()).isEqualTo("stale failure"); + assertThat(version.getStatus()).isEqualTo(SkillVersionStatus.SCANNING); + verify(skillVersionRepository, never()).save(any()); + } + @Test void processScanResult_shouldNotChangeStatusWhenVersionAlreadyPublished() { - SecurityAudit audit = new SecurityAudit(42L, ScannerType.SKILL_SCANNER); + SecurityAudit audit = new SecurityAudit(42L, ScannerType.SKILL_SCANNER, "task-published"); SkillVersion version = new SkillVersion(8L, "1.0.0", "publisher-1"); version.setStatus(SkillVersionStatus.PUBLISHED); + given(auditRepository.findByTaskId("task-published")).willReturn(Optional.of(audit)); given(auditRepository.findLatestActiveByVersionIdAndScannerType(42L, ScannerType.SKILL_SCANNER)) .willReturn(Optional.of(audit)); given(skillVersionRepository.findById(42L)).willReturn(Optional.of(version)); @@ -311,7 +396,7 @@ class SecurityScanServiceTest { 0.5 ); - service.processScanResult(42L, ScannerType.SKILL_SCANNER, response); + service.processScanResult("task-published", 42L, ScannerType.SKILL_SCANNER, response); assertThat(audit.getVerdict()).isEqualTo(SecurityVerdict.SAFE); assertThat(audit.getIsSafe()).isTrue(); @@ -319,6 +404,30 @@ class SecurityScanServiceTest { verify(skillVersionRepository).save(version); } + @Test + void processScanResult_forStaleAttemptDoesNotCompleteCurrentAttempt() throws Exception { + SecurityAudit stale = new SecurityAudit(42L, ScannerType.SKILL_SCANNER, "task-stale"); + SecurityAudit current = new SecurityAudit(42L, ScannerType.SKILL_SCANNER, "task-current"); + SkillVersion version = new SkillVersion(8L, "1.0.0", "publisher-1"); + setId(version, 42L); + version.setStatus(SkillVersionStatus.SCANNING); + given(auditRepository.findByTaskId("task-stale")).willReturn(Optional.of(stale)); + given(auditRepository.findLatestActiveByVersionIdAndScannerType(42L, ScannerType.SKILL_SCANNER)) + .willReturn(Optional.of(current)); + given(skillVersionRepository.findById(42L)).willReturn(Optional.of(version)); + + service.processScanResult( + "task-stale", + 42L, + ScannerType.SKILL_SCANNER, + new SecurityScanResponse("scan-stale", SecurityVerdict.SAFE, 0, null, List.of(), 0.1) + ); + + assertThat(stale.getScanId()).isEqualTo("scan-stale"); + assertThat(version.getStatus()).isEqualTo(SkillVersionStatus.SCANNING); + verify(skillVersionRepository, never()).save(version); + } + private void setId(Object target, Long id) throws Exception { Field field = target.getClass().getDeclaredField("id"); field.setAccessible(true); diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillHardDeleteServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillHardDeleteServiceTest.java index 8808acd4..d50c40b0 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillHardDeleteServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillHardDeleteServiceTest.java @@ -124,7 +124,7 @@ class SkillHardDeleteServiceTest { inOrder.verify(skillRepository).save(skill); inOrder.verify(skillRepository).flush(); inOrder.verify(skillVersionRepository).deleteBySkillId(7L); - verify(reviewTaskRepository).deleteBySkillVersionIdIn(List.of(21L, 22L)); + verify(reviewTaskRepository).deleteBySkillId(7L); verify(promotionRequestRepository).deleteBySourceSkillIdOrTargetSkillId(7L, 7L); verify(skillTagRepository).deleteBySkillId(7L); verify(skillStarRepository).deleteBySkillId(7L); diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillPublishServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillPublishServiceTest.java index a680e320..c0fda2b6 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillPublishServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillPublishServiceTest.java @@ -424,7 +424,7 @@ class SkillPublishServiceTest { } @Test - void testPublishFromEntries_ShouldReplaceRejectedVersionWithSameVersion() throws Exception { + void testPublishFromEntries_ShouldPreserveSettledReviewHistoryWhenReplacingRejectedVersion() throws Exception { String namespaceSlug = "test-ns"; String publisherId = "user-100"; String skillMdContent = "---\nname: test-skill\ndescription: Test\nversion: 1.0.0\n---\nBody"; @@ -473,7 +473,7 @@ class SkillPublishServiceTest { assertEquals("1.0.0", result.version().getVersion()); assertEquals(SkillVersionStatus.PENDING_REVIEW, result.version().getStatus()); - verify(reviewTaskRepository).deleteBySkillVersionIdIn(List.of(8L)); + verify(reviewTaskRepository, never()).deleteBySkillVersionIdIn(List.of(8L)); verify(skillFileRepository).deleteByVersionId(8L); verify(skillVersionRepository).delete(rejectedVersion); verify(skillVersionRepository, times(2)).flush(); @@ -482,6 +482,8 @@ class SkillPublishServiceTest { ArgumentCaptor reviewTaskCaptor = ArgumentCaptor.forClass(ReviewTask.class); verify(reviewTaskRepository).save(reviewTaskCaptor.capture()); assertEquals(result.version().getId(), reviewTaskCaptor.getValue().getSkillVersionId()); + assertEquals(skill.getId(), reviewTaskCaptor.getValue().getSkillId()); + assertEquals("1.0.0", reviewTaskCaptor.getValue().getSkillVersion()); assertEquals(publisherId, reviewTaskCaptor.getValue().getSubmittedBy()); } diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillQueryServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillQueryServiceTest.java index 7fb08e3c..67d34fc4 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillQueryServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillQueryServiceTest.java @@ -1212,6 +1212,57 @@ class SkillQueryServiceTest { assertTrue(result.canInteract()); } + @Test + void testGetSkillDetail_ShouldDisableInteractionForArchivedSkill() throws Exception { + String namespaceSlug = "test-ns"; + String skillSlug = "test-skill"; + String ownerId = "owner-1"; + Map userNsRoles = Map.of(); + + Namespace namespace = new Namespace(namespaceSlug, "Test NS", ownerId); + setId(namespace, 1L); + Skill skill = new Skill(1L, skillSlug, ownerId, SkillVisibility.PUBLIC); + setId(skill, 1L); + skill.setStatus(SkillStatus.ARCHIVED); + skill.setLatestVersionId(11L); + + SkillVersion published = new SkillVersion(1L, "1.0.0", ownerId); + setId(published, 11L); + published.setStatus(SkillVersionStatus.PUBLISHED); + + when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); + when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill)); + when(skillVersionRepository.findById(11L)).thenReturn(Optional.of(published)); + + SkillQueryService.SkillDetailDTO result = service.getSkillDetail(namespaceSlug, skillSlug, ownerId, userNsRoles); + + assertNotNull(result.headlineVersion()); + assertEquals("PUBLISHED", result.headlineVersion().status()); + assertFalse(result.canInteract()); + } + + @Test + void testGetSkillDetail_ShouldKeepInteractionForActiveSkillWithoutHeadlineVersion() throws Exception { + String namespaceSlug = "test-ns"; + String skillSlug = "test-skill"; + String ownerId = "owner-1"; + + Namespace namespace = new Namespace(namespaceSlug, "Test NS", ownerId); + setId(namespace, 1L); + Skill skill = new Skill(1L, skillSlug, ownerId, SkillVisibility.PUBLIC); + setId(skill, 1L); + skill.setStatus(SkillStatus.ACTIVE); + + when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace)); + when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill)); + + SkillQueryService.SkillDetailDTO result = service.getSkillDetail( + namespaceSlug, skillSlug, ownerId, Map.of()); + + assertNull(result.headlineVersion()); + assertTrue(result.canInteract()); + } + @Test void testGetSkillDetail_ShouldIncludeRejectedOwnerPreviewComment() throws Exception { String namespaceSlug = "test-ns"; diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/social/SkillRatingServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/social/SkillRatingServiceTest.java index 00bf2133..53f3a262 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/social/SkillRatingServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/social/SkillRatingServiceTest.java @@ -1,6 +1,7 @@ package com.iflytek.skillhub.domain.social; import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; +import com.iflytek.skillhub.domain.shared.exception.DomainConflictException; import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException; import com.iflytek.skillhub.domain.skill.Skill; import com.iflytek.skillhub.domain.skill.SkillRepository; @@ -11,6 +12,7 @@ import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.*; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.context.ApplicationEventPublisher; +import org.springframework.dao.DataIntegrityViolationException; import java.util.Optional; @@ -77,4 +79,141 @@ class SkillRatingServiceTest { assertThatThrownBy(() -> service.getUserRating(99L, "10")) .isInstanceOf(DomainNotFoundException.class); } + + @Test + void upsertReview_creates_review_and_rating() { + when(skillRepository.findById(1L)).thenReturn(Optional.of(skill())); + when(ratingRepository.findBySkillIdAndUserId(1L, "user-1")).thenReturn(Optional.empty()); + when(ratingRepository.save(any())).thenAnswer(invocation -> invocation.getArgument(0)); + + SkillRating review = service.upsertReview(1L, "user-1", (short) 5, " Useful skill. "); + + assertThat(review.getScore()).isEqualTo((short) 5); + assertThat(review.getReviewText()).isEqualTo("Useful skill."); + assertThat(review.getReviewStatus()).isEqualTo(SkillReviewStatus.VISIBLE); + verify(eventPublisher).publishEvent(any(SkillRatedEvent.class)); + } + + @Test + void upsertReview_preserves_hidden_status_when_author_edits() { + when(skillRepository.findById(1L)).thenReturn(Optional.of(skill())); + SkillRating existing = new SkillRating(1L, "user-1", (short) 2); + existing.updateReview((short) 2, "Original review"); + existing.hideReview("moderator-1", "Policy violation"); + when(ratingRepository.findBySkillIdAndUserId(1L, "user-1")).thenReturn(Optional.of(existing)); + when(ratingRepository.save(any())).thenAnswer(invocation -> invocation.getArgument(0)); + + SkillRating review = service.upsertReview(1L, "user-1", (short) 4, "Edited review"); + + assertThat(review.getReviewStatus()).isEqualTo(SkillReviewStatus.HIDDEN); + assertThat(review.getReviewText()).isEqualTo("Edited review"); + } + + @Test + void upsertReview_rejects_blank_or_too_long_text() { + when(skillRepository.findById(1L)).thenReturn(Optional.of(skill())); + when(ratingRepository.findBySkillIdAndUserId(1L, "user-1")).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service.upsertReview(1L, "user-1", (short) 4, " ")) + .isInstanceOf(DomainBadRequestException.class); + assertThatThrownBy(() -> service.upsertReview(1L, "user-1", (short) 4, "x".repeat(2001))) + .isInstanceOf(DomainBadRequestException.class); + } + + @Test + void upsertReview_mapsConcurrentFirstInsertToConflict() { + when(skillRepository.findById(1L)).thenReturn(Optional.of(skill())); + when(ratingRepository.findBySkillIdAndUserId(1L, "user-1")).thenReturn(Optional.empty()); + when(ratingRepository.save(any())).thenAnswer(invocation -> invocation.getArgument(0)); + doThrow(new DataIntegrityViolationException("duplicate rating")) + .when(ratingRepository).flush(); + + assertThatThrownBy(() -> service.upsertReview(1L, "user-1", (short) 4, "Useful")) + .isInstanceOf(DomainConflictException.class) + .hasMessage("error.request.conflict"); + + verify(eventPublisher, never()).publishEvent(any(SkillRatedEvent.class)); + } + + @Test + void clearReview_keeps_rating_row_and_score() { + when(skillRepository.findById(1L)).thenReturn(Optional.of(skill())); + SkillRating existing = new SkillRating(1L, "user-1", (short) 4); + existing.updateReview((short) 4, "Useful skill"); + when(ratingRepository.findBySkillIdAndUserId(1L, "user-1")).thenReturn(Optional.of(existing)); + when(ratingRepository.save(existing)).thenReturn(existing); + + SkillRating result = service.clearReview(1L, "user-1"); + + assertThat(result.hasReview()).isFalse(); + assertThat(result.getScore()).isEqualTo((short) 4); + assertThat(result.getReviewStatus()).isEqualTo(SkillReviewStatus.VISIBLE); + } + + @Test + void clearAndResubmitReview_preservesHiddenModerationState() { + when(skillRepository.findById(1L)).thenReturn(Optional.of(skill())); + SkillRating existing = new SkillRating(1L, "user-1", (short) 4); + existing.updateReview((short) 4, "Hidden review"); + existing.hideReview("moderator-1", "Policy violation"); + when(ratingRepository.findBySkillIdAndUserId(1L, "user-1")) + .thenReturn(Optional.of(existing)); + when(ratingRepository.save(existing)).thenReturn(existing); + + service.clearReview(1L, "user-1"); + SkillRating resubmitted = service.upsertReview(1L, "user-1", (short) 5, "Rewritten review"); + + assertThat(resubmitted.getReviewStatus()).isEqualTo(SkillReviewStatus.HIDDEN); + assertThat(resubmitted.getModeratedBy()).isEqualTo("moderator-1"); + assertThat(resubmitted.getModerationReason()).isEqualTo("Policy violation"); + assertThat(resubmitted.getReviewText()).isEqualTo("Rewritten review"); + verify(ratingRepository, times(2)).flush(); + } + + @Test + void moderator_can_hide_and_restore_review() { + SkillRating existing = new SkillRating(1L, "user-1", (short) 4); + existing.updateReview((short) 4, "Useful skill"); + when(ratingRepository.findById(7L)).thenReturn(Optional.of(existing)); + when(ratingRepository.save(existing)).thenReturn(existing); + + SkillRating hidden = service.hideReview(7L, "moderator-1", "Off topic"); + assertThat(hidden.getReviewStatus()).isEqualTo(SkillReviewStatus.HIDDEN); + assertThat(hidden.getModeratedBy()).isEqualTo("moderator-1"); + assertThat(hidden.getModerationReason()).isEqualTo("Off topic"); + + SkillRating restored = service.restoreReview(7L, "moderator-2"); + assertThat(restored.getReviewStatus()).isEqualTo(SkillReviewStatus.VISIBLE); + assertThat(restored.getModeratedBy()).isEqualTo("moderator-2"); + assertThat(restored.getModerationReason()).isNull(); + } + + @Test + void moderationReason_acceptsFiveHundredCharactersAndRejectsFiveHundredOne() { + SkillRating valid = new SkillRating(1L, "user-1", (short) 4); + valid.updateReview((short) 4, "Useful skill"); + SkillRating invalid = new SkillRating(1L, "user-2", (short) 4); + invalid.updateReview((short) 4, "Another useful skill"); + when(ratingRepository.findById(7L)).thenReturn(Optional.of(valid)); + when(ratingRepository.findById(8L)).thenReturn(Optional.of(invalid)); + when(ratingRepository.save(valid)).thenReturn(valid); + + SkillRating hidden = service.hideReview(7L, "moderator", "x".repeat(500)); + assertThat(hidden.getModerationReason()).hasSize(500); + + assertThatThrownBy(() -> service.hideReview(8L, "moderator", "x".repeat(501))) + .isInstanceOf(DomainBadRequestException.class) + .hasMessage("error.skillReview.reason.tooLong"); + verify(ratingRepository, never()).save(invalid); + } + + @Test + void clearReview_throws_when_user_has_only_rating() { + when(skillRepository.findById(1L)).thenReturn(Optional.of(skill())); + when(ratingRepository.findBySkillIdAndUserId(1L, "user-1")) + .thenReturn(Optional.of(new SkillRating(1L, "user-1", (short) 4))); + + assertThatThrownBy(() -> service.clearReview(1L, "user-1")) + .isInstanceOf(DomainNotFoundException.class); + } } diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/http/HttpClientException.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/http/HttpClientException.java index b9c5d25f..921bfe7d 100644 --- a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/http/HttpClientException.java +++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/http/HttpClientException.java @@ -12,7 +12,7 @@ public class HttpClientException extends RuntimeException { } public HttpClientException(String message, Throwable cause) { - super(message, cause); + super(message + ": " + rootCauseSummary(cause), cause); this.statusCode = 0; this.responseBody = null; } @@ -24,4 +24,13 @@ public class HttpClientException extends RuntimeException { public String getResponseBody() { return responseBody; } + + private static String rootCauseSummary(Throwable error) { + Throwable root = error; + while (root.getCause() != null && root.getCause() != root) { + root = root.getCause(); + } + String message = root.getMessage(); + return root.getClass().getSimpleName() + (message == null || message.isBlank() ? "" : ": " + message); + } } diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/JpaSkillRatingRepository.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/JpaSkillRatingRepository.java index 6b613ce3..541cdf1f 100644 --- a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/JpaSkillRatingRepository.java +++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/JpaSkillRatingRepository.java @@ -5,6 +5,8 @@ import com.iflytek.skillhub.domain.social.SkillRatingRepository; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.stereotype.Repository; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import java.util.Optional; /** @@ -14,6 +16,25 @@ import java.util.Optional; public interface JpaSkillRatingRepository extends JpaRepository, SkillRatingRepository { Optional findBySkillIdAndUserId(Long skillId, String userId); + @Query(""" + SELECT r FROM SkillRating r + WHERE r.skillId = :skillId + AND r.reviewStatus = com.iflytek.skillhub.domain.social.SkillReviewStatus.VISIBLE + AND r.reviewText IS NOT NULL + AND TRIM(r.reviewText) <> '' + ORDER BY r.updatedAt DESC, r.id DESC + """) + Page findVisibleReviewsBySkillId(Long skillId, Pageable pageable); + + @Query(""" + SELECT r FROM SkillRating r + WHERE r.skillId = :skillId + AND r.reviewText IS NOT NULL + AND TRIM(r.reviewText) <> '' + ORDER BY r.updatedAt DESC, r.id DESC + """) + Page findReviewsBySkillId(Long skillId, Pageable pageable); + @Query("SELECT COALESCE(AVG(r.score), 0) FROM SkillRating r WHERE r.skillId = :skillId") double averageScoreBySkillId(Long skillId); diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/ReviewTaskJpaRepository.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/ReviewTaskJpaRepository.java index 286c0eab..d742064c 100644 --- a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/ReviewTaskJpaRepository.java +++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/ReviewTaskJpaRepository.java @@ -11,6 +11,7 @@ import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; import java.util.Collection; +import java.util.List; import java.util.Optional; /** @@ -28,10 +29,18 @@ public interface ReviewTaskJpaRepository extends JpaRepository Page findBySubmittedByAndStatus(String submittedBy, ReviewTaskStatus status, Pageable pageable); + List findBySubmittedByAndSkillIdAndSkillVersionOrderBySubmittedAtDescIdDesc( + String submittedBy, Long skillId, String skillVersion); + + List findBySkillIdAndSkillVersionOrderBySubmittedAtDescIdDesc( + Long skillId, String skillVersion); + boolean existsByNamespaceId(Long namespaceId); void deleteBySkillVersionIdIn(Collection skillVersionIds); + void deleteBySkillId(Long skillId); + @Modifying @Query(""" UPDATE ReviewTask t diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SecurityAuditJpaRepository.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SecurityAuditJpaRepository.java index a12907db..1f5e8f6b 100644 --- a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SecurityAuditJpaRepository.java +++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SecurityAuditJpaRepository.java @@ -20,6 +20,9 @@ public interface SecurityAuditJpaRepository extends JpaRepository findByScanId(String scanId); + @Override + Optional findByTaskId(String taskId); + @Override boolean existsBySkillVersionId(Long skillVersionId); diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillVersionJpaRepository.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillVersionJpaRepository.java index e7c85e1c..481d6127 100644 --- a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillVersionJpaRepository.java +++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/SkillVersionJpaRepository.java @@ -22,6 +22,14 @@ import org.springframework.stereotype.Repository; */ @Repository public interface SkillVersionJpaRepository extends JpaRepository, SkillVersionRepository { + + @Override + @Query(value = "SELECT * FROM skill_version WHERE id = :id FOR UPDATE", nativeQuery = true) + Optional findByIdForUpdate(@Param("id") Long id); + + @Override + @Query("SELECT version.status FROM SkillVersion version WHERE version.id = :id AND version.skillId = :skillId") + Optional findStatusByIdAndSkillId(@Param("id") Long id, @Param("skillId") Long skillId); List findByIdIn(List ids); List findBySkillId(Long skillId); List findBySkillIdIn(List skillIds); diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/scanner/SecurityScanException.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/scanner/SecurityScanException.java index a11fede4..8f050a33 100644 --- a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/scanner/SecurityScanException.java +++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/scanner/SecurityScanException.java @@ -9,4 +9,15 @@ public class SecurityScanException extends RuntimeException { public SecurityScanException(String message) { super(message); } + + /** + * Returns true when retrying later is safer than permanently failing the skill version. + */ + public boolean isScannerUnavailable() { + if (!(getCause() instanceof com.iflytek.skillhub.infra.http.HttpClientException error)) { + return false; + } + int status = error.getStatusCode(); + return status == 0 || status == 429 || status >= 500; + } } diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/scanner/SkillScannerAdapter.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/scanner/SkillScannerAdapter.java index ad0d8c09..6dd46fcd 100644 --- a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/scanner/SkillScannerAdapter.java +++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/scanner/SkillScannerAdapter.java @@ -40,7 +40,7 @@ public class SkillScannerAdapter implements SecurityScanner { return mapToResponse(apiResponse); } catch (HttpClientException e) { log.error("Security scan failed for versionId={}: {}", request.skillVersionId(), e.getMessage()); - throw new SecurityScanException("Security scan failed", e); + throw new SecurityScanException("Security scan request failed: " + e.getMessage(), e); } } diff --git a/server/skillhub-infra/src/test/java/com/iflytek/skillhub/infra/scanner/SkillScannerAdapterTest.java b/server/skillhub-infra/src/test/java/com/iflytek/skillhub/infra/scanner/SkillScannerAdapterTest.java index e7eff1f1..29c62316 100644 --- a/server/skillhub-infra/src/test/java/com/iflytek/skillhub/infra/scanner/SkillScannerAdapterTest.java +++ b/server/skillhub-infra/src/test/java/com/iflytek/skillhub/infra/scanner/SkillScannerAdapterTest.java @@ -7,6 +7,8 @@ import com.iflytek.skillhub.domain.security.SecurityVerdict; import com.iflytek.skillhub.infra.http.HttpClient; import com.iflytek.skillhub.infra.http.HttpClientException; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; import org.springframework.http.HttpHeaders; import java.nio.file.Path; @@ -87,16 +89,45 @@ class SkillScannerAdapterTest { assertThat(response.verdict()).isEqualTo(SecurityVerdict.BLOCKED); } - @Test - void scan_wrapsHttpClientFailureAsSecurityScanException() { + @ParameterizedTest + @ValueSource(ints = {429, 500, 599}) + void scan_treatsTransientHttpFailureAsScannerUnavailable(int statusCode) { StubSkillScannerService skillScannerService = new StubSkillScannerService(); - skillScannerService.directoryException = new HttpClientException(502, "bad gateway"); + skillScannerService.directoryException = new HttpClientException(statusCode, "scanner unavailable"); ScanOptions options = ScanOptions.disabled(); SkillScannerAdapter adapter = new SkillScannerAdapter(skillScannerService, "local", options); assertThatThrownBy(() -> adapter.scan(new SecurityScanRequest("task-1", 42L, "/tmp/skill", Map.of()))) .isInstanceOf(SecurityScanException.class) - .hasMessage("Security scan failed"); + .hasMessage("Security scan request failed: HTTP " + statusCode + ": scanner unavailable") + .satisfies(error -> assertThat(((SecurityScanException) error).isScannerUnavailable()).isTrue()); + } + + @Test + void scan_treatsConnectionFailureAsScannerUnavailable() { + StubSkillScannerService skillScannerService = new StubSkillScannerService(); + skillScannerService.directoryException = + new HttpClientException("request failed", new IllegalStateException("connection refused")); + SkillScannerAdapter adapter = new SkillScannerAdapter( + skillScannerService, "local", ScanOptions.disabled()); + + assertThatThrownBy(() -> adapter.scan(new SecurityScanRequest("task-1", 42L, "/tmp/skill", Map.of()))) + .isInstanceOf(SecurityScanException.class) + .satisfies(error -> assertThat(((SecurityScanException) error).isScannerUnavailable()).isTrue()); + } + + @ParameterizedTest + @ValueSource(ints = {400, 422, 499}) + void scan_treatsDeterministicClientFailureAsPermanent(int statusCode) { + StubSkillScannerService skillScannerService = new StubSkillScannerService(); + skillScannerService.directoryException = new HttpClientException(statusCode, "invalid package"); + SkillScannerAdapter adapter = new SkillScannerAdapter( + skillScannerService, "local", ScanOptions.disabled()); + + assertThatThrownBy(() -> adapter.scan( + new SecurityScanRequest("task-1", 42L, "/tmp/skill", Map.of()))) + .isInstanceOf(SecurityScanException.class) + .satisfies(error -> assertThat(((SecurityScanException) error).isScannerUnavailable()).isFalse()); } private static final class StubSkillScannerService extends SkillScannerService { diff --git a/server/skillhub-notification/pom.xml b/server/skillhub-notification/pom.xml index 5c01d1ba..f62e3a8a 100644 --- a/server/skillhub-notification/pom.xml +++ b/server/skillhub-notification/pom.xml @@ -14,10 +14,6 @@ com.iflytek.skillhub skillhub-domain - - org.springframework.boot - spring-boot-starter-web - org.springframework.boot spring-boot-starter-data-jpa diff --git a/server/skillhub-notification/src/main/java/com/iflytek/skillhub/notification/service/NotificationDispatcher.java b/server/skillhub-notification/src/main/java/com/iflytek/skillhub/notification/service/NotificationDispatcher.java deleted file mode 100644 index e162dbd9..00000000 --- a/server/skillhub-notification/src/main/java/com/iflytek/skillhub/notification/service/NotificationDispatcher.java +++ /dev/null @@ -1,60 +0,0 @@ -package com.iflytek.skillhub.notification.service; - -import com.iflytek.skillhub.notification.domain.NotificationCategory; -import com.iflytek.skillhub.notification.domain.NotificationChannel; -import com.iflytek.skillhub.notification.domain.Notification; -import com.iflytek.skillhub.notification.sse.SseEmitterManager; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.stereotype.Service; - -import java.util.Map; - -@Service -public class NotificationDispatcher { - - private static final Logger log = LoggerFactory.getLogger(NotificationDispatcher.class); - - private final NotificationService notificationService; - private final NotificationPreferenceService preferenceService; - private final SseEmitterManager sseEmitterManager; - - public NotificationDispatcher(NotificationService notificationService, - NotificationPreferenceService preferenceService, - SseEmitterManager sseEmitterManager) { - this.notificationService = notificationService; - this.preferenceService = preferenceService; - this.sseEmitterManager = sseEmitterManager; - } - - public void dispatch(String recipientId, NotificationCategory category, - String eventType, String title, String bodyJson, - String entityType, Long entityId) { - // Check user preference - if (!preferenceService.isEnabled(recipientId, category, NotificationChannel.IN_APP)) { - log.debug("Notification {} suppressed for user {} (preference disabled)", eventType, recipientId); - return; - } - - // Persist notification - Notification notification = notificationService.create( - recipientId, category, eventType, title, bodyJson, entityType, entityId); - - // Push via SSE - try { - sseEmitterManager.push(recipientId, Map.of( - "id", notification.getId(), - "category", notification.getCategory().name(), - "eventType", notification.getEventType(), - "title", notification.getTitle(), - "bodyJson", notification.getBodyJson() != null ? notification.getBodyJson() : "", - "entityType", notification.getEntityType() != null ? notification.getEntityType() : "", - "entityId", notification.getEntityId() != null ? notification.getEntityId() : 0, - "createdAt", notification.getCreatedAt().toString() - )); - } catch (Exception e) { - log.warn("Failed to push SSE notification to user {}", recipientId, e); - // Notification is already persisted, SSE push failure is non-critical - } - } -} diff --git a/server/skillhub-notification/src/main/java/com/iflytek/skillhub/notification/service/NotificationService.java b/server/skillhub-notification/src/main/java/com/iflytek/skillhub/notification/service/NotificationService.java index e0a72837..62747778 100644 --- a/server/skillhub-notification/src/main/java/com/iflytek/skillhub/notification/service/NotificationService.java +++ b/server/skillhub-notification/src/main/java/com/iflytek/skillhub/notification/service/NotificationService.java @@ -16,20 +16,27 @@ import java.time.Instant; public class NotificationService { private final NotificationRepository notificationRepository; + private final NotificationPreferenceService preferenceService; private final Clock clock; - public NotificationService(NotificationRepository notificationRepository, Clock clock) { + public NotificationService(NotificationRepository notificationRepository, + NotificationPreferenceService preferenceService, + Clock clock) { this.notificationRepository = notificationRepository; + this.preferenceService = preferenceService; this.clock = clock; } @Transactional - public Notification create(String recipientId, NotificationCategory category, - String eventType, String title, String bodyJson, - String entityType, Long entityId) { + public void create(String recipientId, NotificationCategory category, + String eventType, String title, String bodyJson, + String entityType, Long entityId) { + if (!preferenceService.isEnabled(recipientId, category, NotificationChannel.IN_APP)) { + return; + } Notification notification = new Notification(recipientId, category, eventType, title, bodyJson, entityType, entityId, Instant.now(clock)); - return notificationRepository.save(notification); + notificationRepository.save(notification); } @Transactional(readOnly = true) diff --git a/server/skillhub-notification/src/main/java/com/iflytek/skillhub/notification/sse/SseEmitterManager.java b/server/skillhub-notification/src/main/java/com/iflytek/skillhub/notification/sse/SseEmitterManager.java deleted file mode 100644 index 341a4d50..00000000 --- a/server/skillhub-notification/src/main/java/com/iflytek/skillhub/notification/sse/SseEmitterManager.java +++ /dev/null @@ -1,136 +0,0 @@ -package com.iflytek.skillhub.notification.sse; - -import java.io.IOException; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.CopyOnWriteArrayList; -import java.util.concurrent.atomic.AtomicBoolean; -import java.util.concurrent.atomic.AtomicInteger; -import java.util.function.Function; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.scheduling.annotation.Scheduled; -import org.springframework.stereotype.Component; -import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; - -@Component -public class SseEmitterManager { - - private static final Logger log = LoggerFactory.getLogger(SseEmitterManager.class); - private static final long SSE_TIMEOUT = 10 * 60_000L; - private static final long HEARTBEAT_INTERVAL = 30_000L; - private static final int MAX_EMITTERS_PER_USER = 5; - private static final int MAX_TOTAL_EMITTERS = 1000; - - private final ConcurrentHashMap> emitters = new ConcurrentHashMap<>(); - private final AtomicInteger totalCount = new AtomicInteger(0); - private final Function emitterFactory; - - public SseEmitterManager() { - this(userId -> new SseEmitter(SSE_TIMEOUT)); - } - - SseEmitterManager(Function emitterFactory) { - this.emitterFactory = emitterFactory; - } - - public SseEmitter register(String userId) { - if (totalCount.get() >= MAX_TOTAL_EMITTERS) { - throw new IllegalStateException("SSE connection limit reached"); - } - - CopyOnWriteArrayList userEmitters = emitters.computeIfAbsent(userId, k -> new CopyOnWriteArrayList<>()); - if (userEmitters.size() >= MAX_EMITTERS_PER_USER) { - TrackedEmitter oldest = userEmitters.get(0); - cleanup(userId, userEmitters, oldest); - try { - oldest.emitter().complete(); - } catch (IllegalStateException ex) { - log.debug("Emitter already completed during eviction for user {}", userId); - } - } - - TrackedEmitter trackedEmitter = new TrackedEmitter(emitterFactory.apply(userId)); - userEmitters.add(trackedEmitter); - totalCount.incrementAndGet(); - - Runnable cleanup = () -> cleanup(userId, userEmitters, trackedEmitter); - trackedEmitter.emitter().onCompletion(cleanup); - trackedEmitter.emitter().onTimeout(cleanup); - trackedEmitter.emitter().onError(e -> cleanup.run()); - - try { - trackedEmitter.emitter().send(SseEmitter.event().name("connected").data("ok")); - } catch (IOException e) { - cleanup.run(); - } - - return trackedEmitter.emitter(); - } - - public void push(String userId, Object data) { - CopyOnWriteArrayList userEmitters = emitters.get(userId); - if (userEmitters == null) return; - - for (TrackedEmitter trackedEmitter : userEmitters) { - try { - trackedEmitter.emitter().send(SseEmitter.event().name("notification").data(data)); - } catch (IOException e) { - log.debug("Failed to push to user {}, removing emitter", userId); - cleanup(userId, userEmitters, trackedEmitter); - } - } - } - - @Scheduled(fixedRate = HEARTBEAT_INTERVAL) - public void heartbeat() { - emitters.forEach((userId, userEmitters) -> { - for (TrackedEmitter trackedEmitter : userEmitters) { - try { - trackedEmitter.emitter().send(SseEmitter.event().comment("ping")); - } catch (IOException e) { - log.debug("Heartbeat failed for user {}", userId); - cleanup(userId, userEmitters, trackedEmitter); - } - } - }); - } - - int totalEmitters() { - return totalCount.get(); - } - - int emittersForUser(String userId) { - return emitters.getOrDefault(userId, new CopyOnWriteArrayList<>()).size(); - } - - public static long defaultTimeoutMillis() { - return SSE_TIMEOUT; - } - - public static long heartbeatIntervalMillis() { - return HEARTBEAT_INTERVAL; - } - - private void cleanup(String userId, - CopyOnWriteArrayList userEmitters, - TrackedEmitter trackedEmitter) { - if (!trackedEmitter.markCleaned()) { - return; - } - userEmitters.remove(trackedEmitter); - totalCount.decrementAndGet(); - if (userEmitters.isEmpty()) { - emitters.remove(userId, userEmitters); - } - } - - private record TrackedEmitter(SseEmitter emitter, AtomicBoolean cleaned) { - private TrackedEmitter(SseEmitter emitter) { - this(emitter, new AtomicBoolean(false)); - } - - boolean markCleaned() { - return cleaned.compareAndSet(false, true); - } - } -} diff --git a/server/skillhub-notification/src/test/java/com/iflytek/skillhub/notification/service/NotificationDispatcherTest.java b/server/skillhub-notification/src/test/java/com/iflytek/skillhub/notification/service/NotificationDispatcherTest.java deleted file mode 100644 index db6af43b..00000000 --- a/server/skillhub-notification/src/test/java/com/iflytek/skillhub/notification/service/NotificationDispatcherTest.java +++ /dev/null @@ -1,128 +0,0 @@ -package com.iflytek.skillhub.notification.service; - -import com.iflytek.skillhub.notification.domain.Notification; -import com.iflytek.skillhub.notification.domain.NotificationCategory; -import com.iflytek.skillhub.notification.domain.NotificationChannel; -import com.iflytek.skillhub.notification.sse.SseEmitterManager; -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 java.time.Instant; -import java.util.Map; - -import org.mockito.ArgumentCaptor; - -import static org.assertj.core.api.Assertions.assertThat; - -import static org.mockito.ArgumentMatchers.*; -import static org.mockito.Mockito.*; - -@ExtendWith(MockitoExtension.class) -class NotificationDispatcherTest { - - @Mock private NotificationService notificationService; - @Mock private NotificationPreferenceService preferenceService; - @Mock private SseEmitterManager sseEmitterManager; - - private NotificationDispatcher dispatcher; - - @BeforeEach - void setUp() { - dispatcher = new NotificationDispatcher(notificationService, preferenceService, sseEmitterManager); - } - - private Notification buildNotificationMock() { - Notification n = mock(Notification.class); - lenient().when(n.getId()).thenReturn(1L); - lenient().when(n.getCategory()).thenReturn(NotificationCategory.REVIEW); - lenient().when(n.getEventType()).thenReturn("review.approved"); - lenient().when(n.getTitle()).thenReturn("Title"); - lenient().when(n.getBodyJson()).thenReturn("{}"); - lenient().when(n.getEntityType()).thenReturn("skill"); - lenient().when(n.getEntityId()).thenReturn(1L); - lenient().when(n.getCreatedAt()).thenReturn(Instant.parse("2026-03-19T10:00:00Z")); - return n; - } - - @Test - void dispatch_shouldPersistAndPushWhenEnabled() { - Notification notification = buildNotificationMock(); - when(preferenceService.isEnabled("user-1", NotificationCategory.REVIEW, NotificationChannel.IN_APP)) - .thenReturn(true); - when(notificationService.create(any(), any(), any(), any(), any(), any(), any())) - .thenReturn(notification); - - dispatcher.dispatch("user-1", NotificationCategory.REVIEW, - "review.approved", "Title", "{}", "skill", 1L); - - verify(notificationService).create("user-1", NotificationCategory.REVIEW, - "review.approved", "Title", "{}", "skill", 1L); - verify(sseEmitterManager).push(eq("user-1"), any()); - } - - @Test - void dispatch_persistsExactSubscriberNotificationAndPushesSameRecipientVisiblePayload() { - Notification notification = new Notification("subscriber-1", NotificationCategory.PUBLISH, - "SUBSCRIPTION_NEW_VERSION", "Skill updated: Demo", - "{\"skillId\":1,\"versionId\":10}", "SKILL", 1L, - Instant.parse("2026-08-19T20:30:00Z")); - try { - var id = Notification.class.getDeclaredField("id"); - id.setAccessible(true); - id.set(notification, 42L); - } catch (ReflectiveOperationException e) { - throw new IllegalStateException(e); - } - when(preferenceService.isEnabled("subscriber-1", NotificationCategory.PUBLISH, - NotificationChannel.IN_APP)).thenReturn(true); - when(notificationService.create(any(), any(), any(), any(), any(), any(), any())) - .thenReturn(notification); - - dispatcher.dispatch("subscriber-1", NotificationCategory.PUBLISH, "SUBSCRIPTION_NEW_VERSION", - "Skill updated: Demo", "{\"skillId\":1,\"versionId\":10}", "SKILL", 1L); - - verify(notificationService).create("subscriber-1", NotificationCategory.PUBLISH, - "SUBSCRIPTION_NEW_VERSION", "Skill updated: Demo", - "{\"skillId\":1,\"versionId\":10}", "SKILL", 1L); - @SuppressWarnings("unchecked") - ArgumentCaptor> payload = ArgumentCaptor.forClass(Map.class); - verify(sseEmitterManager).push(eq("subscriber-1"), payload.capture()); - assertThat(payload.getValue()).containsEntry("id", 42L) - .containsEntry("category", "PUBLISH") - .containsEntry("eventType", "SUBSCRIPTION_NEW_VERSION") - .containsEntry("bodyJson", "{\"skillId\":1,\"versionId\":10}") - .containsEntry("entityType", "SKILL") - .containsEntry("entityId", 1L); - } - - @Test - void dispatch_shouldSkipWhenPreferenceDisabled() { - when(preferenceService.isEnabled("user-1", NotificationCategory.REVIEW, NotificationChannel.IN_APP)) - .thenReturn(false); - - dispatcher.dispatch("user-1", NotificationCategory.REVIEW, - "review.approved", "Title", "{}", "skill", 1L); - - verify(notificationService, never()).create(any(), any(), any(), any(), any(), any(), any()); - verify(sseEmitterManager, never()).push(any(), any()); - } - - @Test - void dispatch_shouldStillPersistWhenSsePushFails() { - Notification notification = buildNotificationMock(); - when(preferenceService.isEnabled("user-1", NotificationCategory.REVIEW, NotificationChannel.IN_APP)) - .thenReturn(true); - when(notificationService.create(any(), any(), any(), any(), any(), any(), any())) - .thenReturn(notification); - doThrow(new RuntimeException("SSE failure")).when(sseEmitterManager).push(any(), any()); - - dispatcher.dispatch("user-1", NotificationCategory.REVIEW, - "review.approved", "Title", "{}", "skill", 1L); - - verify(notificationService).create("user-1", NotificationCategory.REVIEW, - "review.approved", "Title", "{}", "skill", 1L); - } -} diff --git a/server/skillhub-notification/src/test/java/com/iflytek/skillhub/notification/service/NotificationServiceTest.java b/server/skillhub-notification/src/test/java/com/iflytek/skillhub/notification/service/NotificationServiceTest.java index e92179cb..7f4eb10c 100644 --- a/server/skillhub-notification/src/test/java/com/iflytek/skillhub/notification/service/NotificationServiceTest.java +++ b/server/skillhub-notification/src/test/java/com/iflytek/skillhub/notification/service/NotificationServiceTest.java @@ -27,30 +27,40 @@ import static org.mockito.Mockito.*; class NotificationServiceTest { @Mock private NotificationRepository notificationRepository; + @Mock private NotificationPreferenceService preferenceService; private Clock clock; private NotificationService service; @BeforeEach void setUp() { clock = Clock.fixed(Instant.parse("2026-03-19T10:00:00Z"), ZoneOffset.UTC); - service = new NotificationService(notificationRepository, clock); + service = new NotificationService(notificationRepository, preferenceService, clock); } @Test - void createNotification_shouldSaveAndReturn() { - Notification notification = new Notification("user-1", NotificationCategory.REVIEW, - "review.approved", "notification.review.approved", - "{\"skillName\":\"test\"}", "skill", 1L, Instant.now(clock)); - when(notificationRepository.save(any())).thenReturn(notification); + void createNotification_shouldSaveWhenEnabled() { + when(preferenceService.isEnabled("user-1", NotificationCategory.REVIEW, NotificationChannel.IN_APP)) + .thenReturn(true); - Notification result = service.create("user-1", NotificationCategory.REVIEW, + service.create("user-1", NotificationCategory.REVIEW, "review.approved", "notification.review.approved", "{\"skillName\":\"test\"}", "skill", 1L); - assertNotNull(result); verify(notificationRepository).save(any(Notification.class)); } + @Test + void createNotification_shouldSkipWhenPreferenceDisabled() { + when(preferenceService.isEnabled("user-1", NotificationCategory.REVIEW, NotificationChannel.IN_APP)) + .thenReturn(false); + + service.create("user-1", NotificationCategory.REVIEW, + "review.approved", "notification.review.approved", + "{\"skillName\":\"test\"}", "skill", 1L); + + verifyNoInteractions(notificationRepository); + } + @Test void getUnreadCount_shouldReturnCount() { when(notificationRepository.countByRecipientIdAndStatus("user-1", NotificationStatus.UNREAD)) diff --git a/server/skillhub-notification/src/test/java/com/iflytek/skillhub/notification/sse/SseEmitterManagerTest.java b/server/skillhub-notification/src/test/java/com/iflytek/skillhub/notification/sse/SseEmitterManagerTest.java deleted file mode 100644 index 43646817..00000000 --- a/server/skillhub-notification/src/test/java/com/iflytek/skillhub/notification/sse/SseEmitterManagerTest.java +++ /dev/null @@ -1,256 +0,0 @@ -package com.iflytek.skillhub.notification.sse; - -import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.junit.jupiter.api.Assertions.assertNotNull; - -import java.io.IOException; -import java.util.ArrayDeque; -import java.util.ArrayList; -import java.util.List; -import java.util.Map; -import java.util.Queue; -import java.util.concurrent.atomic.AtomicInteger; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyEmitter; -import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; - -class SseEmitterManagerTest { - - private Queue emitters; - private SseEmitterManager manager; - - @BeforeEach - void setUp() { - emitters = new ArrayDeque<>(); - manager = new SseEmitterManager(userId -> { - TestEmitter emitter = emitters.remove(); - emitter.registerUser(userId); - return emitter; - }); - } - - @Test - void register_shouldReturnEmitter() { - TestEmitter testEmitter = new TestEmitter(); - emitters.add(testEmitter); - - SseEmitter emitter = manager.register("user-1"); - - assertNotNull(emitter); - assertEquals(1, manager.totalEmitters()); - assertEquals(1, manager.emittersForUser("user-1")); - assertEquals(1, testEmitter.sentEventCount()); - assertTrue(testEmitter.sentEventData(0).stream() - .anyMatch(value -> value.toString().contains("event:connected"))); - assertTrue(testEmitter.sentEventData(0).contains("ok")); - assertTrue(testEmitter.isOpen()); - } - - @Test - void defaultTimeout_shouldOutliveManyHeartbeats() { - assertTrue(SseEmitterManager.defaultTimeoutMillis() >= 10 * 60_000L); - assertTrue(SseEmitterManager.defaultTimeoutMillis() > SseEmitterManager.heartbeatIntervalMillis() * 2); - } - - @Test - void register_shouldKeepAccurateCountWhenEvictingOldestEmitter() { - for (int i = 0; i < 6; i++) { - emitters.add(new TestEmitter()); - } - - for (int i = 0; i < 6; i++) { - manager.register("user-evict"); - } - - assertEquals(5, manager.totalEmitters()); - assertEquals(5, manager.emittersForUser("user-evict")); - } - - @Test - void register_shouldTolerateEmitterThatThrowsDuringEvictionCompletion() { - TestEmitter oldest = new TestEmitter(); - oldest.throwOnComplete(); - emitters.add(oldest); - for (int i = 0; i < 5; i++) { - emitters.add(new TestEmitter()); - } - - for (int i = 0; i < 5; i++) { - manager.register("user-evict"); - } - - assertDoesNotThrow(() -> manager.register("user-evict")); - assertEquals(5, manager.totalEmitters()); - assertEquals(5, manager.emittersForUser("user-evict")); - } - - @Test - void push_shouldRemoveEmitterWhenSendFails() { - TestEmitter healthy = new TestEmitter(); - TestEmitter broken = new TestEmitter(); - broken.failAfterConnected(); - emitters.add(healthy); - emitters.add(broken); - manager.register("user-1"); - manager.register("user-1"); - - manager.push("user-1", "payload"); - - assertEquals(1, manager.totalEmitters()); - assertEquals(1, manager.emittersForUser("user-1")); - } - - @Test - void push_shouldSendNotificationEventToRegisteredOpenEmitter() { - TestEmitter emitter = new TestEmitter(); - emitters.add(emitter); - manager.register("user-1"); - - Map payload = Map.of( - "id", 42L, - "eventType", "PROFILE_REVIEW_SUBMITTED" - ); - manager.push("user-1", payload); - - assertEquals(2, emitter.sentEventCount()); - assertTrue(emitter.sentEventData(1).stream() - .anyMatch(value -> value.toString().contains("event:notification"))); - assertTrue(emitter.sentEventData(1).contains(payload)); - assertTrue(emitter.isOpen()); - assertEquals(1, manager.totalEmitters()); - assertEquals(1, manager.emittersForUser("user-1")); - } - - @Test - void heartbeat_shouldRemoveEmitterWhenSendFails() { - TestEmitter healthy = new TestEmitter(); - TestEmitter broken = new TestEmitter(); - broken.failAfterConnected(); - emitters.add(healthy); - emitters.add(broken); - manager.register("user-1"); - manager.register("user-1"); - - manager.heartbeat(); - - assertEquals(1, manager.totalEmitters()); - assertEquals(1, manager.emittersForUser("user-1")); - } - - @Test - void cleanup_shouldBeIdempotent() { - TestEmitter emitter = new TestEmitter(); - emitters.add(emitter); - manager.register("user-1"); - - emitter.fireError(); - emitter.fireError(); - - assertEquals(0, manager.totalEmitters()); - assertEquals(0, manager.emittersForUser("user-1")); - } - - @Test - void push_shouldDoNothingForUnregisteredUser() { - assertDoesNotThrow(() -> manager.push("unknown-user", "some-data")); - assertEquals(0, manager.totalEmitters()); - } - - @Test - void register_multipleUsers_shouldTrackSeparately() { - emitters.add(new TestEmitter()); - emitters.add(new TestEmitter()); - - SseEmitter emitter1 = manager.register("user-1"); - SseEmitter emitter2 = manager.register("user-2"); - - assertNotNull(emitter1); - assertNotNull(emitter2); - assertEquals(2, manager.totalEmitters()); - assertEquals(1, manager.emittersForUser("user-1")); - assertEquals(1, manager.emittersForUser("user-2")); - } - - private static final class TestEmitter extends SseEmitter { - private final AtomicInteger errorCallbacks = new AtomicInteger(0); - private Runnable completionCallback = () -> {}; - private Runnable timeoutCallback = () -> {}; - private java.util.function.Consumer errorCallback = error -> {}; - private String userId; - private boolean failAfterConnected; - private boolean throwOnComplete; - private int sendCount; - private boolean completed; - private final List> sentEvents = new ArrayList<>(); - - private TestEmitter() { - super(60_000L); - } - - void registerUser(String userId) { - this.userId = userId; - } - - void failAfterConnected() { - this.failAfterConnected = true; - } - - void throwOnComplete() { - this.throwOnComplete = true; - } - - void fireError() { - errorCallback.accept(new IOException("boom-" + userId + "-" + errorCallbacks.incrementAndGet())); - } - - boolean isOpen() { - return !completed; - } - - int sentEventCount() { - return sentEvents.size(); - } - - List sentEventData(int index) { - return sentEvents.get(index); - } - - @Override - public synchronized void onCompletion(Runnable callback) { - this.completionCallback = callback; - } - - @Override - public synchronized void onTimeout(Runnable callback) { - this.timeoutCallback = callback; - } - - @Override - public synchronized void onError(java.util.function.Consumer callback) { - this.errorCallback = callback; - } - - @Override - public void complete() { - if (throwOnComplete) { - throw new IllegalStateException("already complete"); - } - completed = true; - completionCallback.run(); - } - - @Override - public void send(SseEventBuilder builder) throws IOException { - sendCount++; - if (failAfterConnected && sendCount > 1) { - throw new IOException("send failed"); - } - sentEvents.add(builder.build().stream() - .map(ResponseBodyEmitter.DataWithMediaType::getData) - .toList()); - } - } -} diff --git a/web/e2e/helpers/session.ts b/web/e2e/helpers/session.ts index 27e5e4e2..d1616d7a 100644 --- a/web/e2e/helpers/session.ts +++ b/web/e2e/helpers/session.ts @@ -111,12 +111,20 @@ async function cacheAccountSession(page: Page, username: string) { }) } -async function restoreCachedSession(page: Page, worker: number): Promise { +async function restoreCachedSession( + page: Page, + worker: number, + allowMockSession = true, +): Promise { const snapshot = cachedSessionByWorker.get(worker) if (!snapshot) { return null } + if (!allowMockSession && snapshot.username === 'local-user') { + return null + } + await page.context().addCookies(snapshot.cookies) if (await hasActiveSession(page)) { return snapshot @@ -170,20 +178,22 @@ async function tryBootstrapMockSession(page: Page, worker: number): Promise<{ us async function registerSessionOnce(page: Page, testInfo?: TestInfo, options?: RegisterSessionOptions) { const worker = testInfo?.parallelIndex ?? 0 - const cached = cachedUserByWorker.get(worker) + const allowMockSession = options?.allowMockSession !== false + const cachedUsername = cachedUserByWorker.get(worker) + const cached = !allowMockSession && cachedUsername === 'local-user' ? undefined : cachedUsername const username = usernameForWorker(testInfo) const request = page.context().request await primeAuthProviders(page) // Avoid hammering auth endpoints on every test run for the same worker. - const restored = await restoreCachedSession(page, worker) + const restored = await restoreCachedSession(page, worker, allowMockSession) if (restored) { cachedUserByWorker.set(worker, restored.username) return { username: restored.username, password } } - if (options?.allowMockSession !== false) { + if (allowMockSession) { const mockSession = await tryBootstrapMockSession(page, worker) if (mockSession) { return mockSession diff --git a/web/e2e/promotions-review.spec.ts b/web/e2e/promotions-review.spec.ts index af7d98a2..400ab72a 100644 --- a/web/e2e/promotions-review.spec.ts +++ b/web/e2e/promotions-review.spec.ts @@ -87,13 +87,6 @@ test.describe('Promotion review dashboard', () => { }), }) }) - await page.route('**/api/web/notifications/sse', async (route) => { - await route.fulfill({ - status: 200, - contentType: 'text/event-stream', - body: '', - }) - }) }) async function installPromotionRouteMock(page: Page, expectedSignatures: string[]) { diff --git a/web/e2e/public-skill-detail-anonymous.spec.ts b/web/e2e/public-skill-detail-anonymous.spec.ts index f5834812..feccff24 100644 --- a/web/e2e/public-skill-detail-anonymous.spec.ts +++ b/web/e2e/public-skill-detail-anonymous.spec.ts @@ -44,7 +44,9 @@ test.describe('Public Skill Detail Anonymous Access (Real API)', () => { await expect(page).toHaveURL(new RegExp(`/space/${current.skill.namespace}/${current.skill.slug}(\\?|$)`)) await expect(page).not.toHaveURL(/\/login\?returnTo=/) - await expect(page.getByRole('heading', { name: current.skillName, exact: true })).toBeVisible() + const skillNameHeadings = page.getByRole('heading', { name: current.skillName, exact: true }) + await expect(skillNameHeadings).toHaveCount(2) + await expect(skillNameHeadings.first()).toBeVisible() await expect(page.getByText('Install', { exact: true })).toBeVisible() const clawhubTarget = current.skill.namespace === 'global' ? current.skill.slug @@ -53,14 +55,13 @@ test.describe('Public Skill Detail Anonymous Access (Real API)', () => { ? '' : ` --namespace ${current.skill.namespace}` - await expect(page.getByRole('tab', { name: 'ClawHub CLI' })).toHaveAttribute('aria-selected', 'true') - await expect(page.getByText(new RegExp(`npx clawhub install ${escapeRegExp(clawhubTarget)} --registry`))).toBeVisible() - await expect(page.getByRole('tab', { name: 'SkillHub CLI' })).toBeVisible() - - await page.getByRole('tab', { name: 'SkillHub CLI' }).click() - await expect(page.getByRole('tab', { name: 'SkillHub CLI' })).toHaveAttribute('aria-selected', 'true') await expect(page.getByText(new RegExp(`npx @astron-team/skillhub@latest install ${escapeRegExp(current.skill.slug)}${escapeRegExp(skillhubNamespace)} --registry`))).toBeVisible() await expect(page.getByRole('button', { name: 'Copy' }).first()).toBeVisible() + + await page.getByRole('tab', { name: 'ClawHub CLI' }).click() + + await expect(page.getByRole('tab', { name: 'ClawHub CLI' })).toHaveAttribute('aria-selected', 'true') + await expect(page.getByText(new RegExp(`npx clawhub install ${escapeRegExp(clawhubTarget)} --registry`))).toBeVisible() }) }) diff --git a/web/e2e/rejected-version-republish.spec.ts b/web/e2e/rejected-version-republish.spec.ts index cbbcada4..8f4911e6 100644 --- a/web/e2e/rejected-version-republish.spec.ts +++ b/web/e2e/rejected-version-republish.spec.ts @@ -1,6 +1,6 @@ import { expect, test } from '@playwright/test' import { setEnglishLocale } from './helpers/auth-fixtures' -import { loginWithCredentials, registerSession } from './helpers/session' +import { createFreshSession, loginWithCredentials } from './helpers/session' import { E2eTestDataBuilder } from './helpers/test-data-builder' function getOptionalEnv(name: string): string | undefined { @@ -15,20 +15,39 @@ function adminCredentials() { } } +function withoutKnownMetaCspWarning(messages: string[]): string[] { + return messages.filter((message) => ( + !message.includes("frame-ancestors' is ignored when delivered via a element") + )) +} + test.describe('Rejected version replacement (Real API)', () => { test.describe.configure({ timeout: 150_000 }) test.beforeEach(async ({ page }, testInfo) => { await setEnglishLocale(page) - await registerSession(page, testInfo) + await createFreshSession(page, testInfo) }) test('re-publishes the same version after rejection', async ({ page, browser }, testInfo) => { + const consoleErrors: string[] = [] + const pageErrors: string[] = [] + const adminConsoleErrors: string[] = [] + const adminPageErrors: string[] = [] + page.on('console', (message) => { + if (message.type() === 'error') consoleErrors.push(message.text()) + }) + page.on('pageerror', (error) => pageErrors.push(error.message)) + const publisherBuilder = new E2eTestDataBuilder(page, testInfo) await publisherBuilder.init() const adminContext = await browser.newContext() const adminPage = await adminContext.newPage() + adminPage.on('console', (message) => { + if (message.type() === 'error') adminConsoleErrors.push(message.text()) + }) + adminPage.on('pageerror', (error) => adminPageErrors.push(error.message)) const adminBuilder = new E2eTestDataBuilder(adminPage, testInfo) await loginWithCredentials(adminPage, adminCredentials(), testInfo) await adminBuilder.init() @@ -52,6 +71,23 @@ test.describe('Rejected version replacement (Real API)', () => { 'PENDING_REVIEW', ) await adminBuilder.rejectReview(rejectedReviewId) + await publisherBuilder.waitForVersionStatus( + namespace.slug, + firstPublish.slug, + firstPublish.version, + 'REJECTED', + ) + + await page.goto('/dashboard/review-progress') + await expect(page.getByRole('heading', { name: 'My Review Progress' })).toBeVisible() + const rejectedCard = page.locator('article').filter({ hasText: firstPublish.slug }) + await expect(rejectedCard).toContainText('Rejected') + await expect(rejectedCard).toContainText('1 submission') + await rejectedCard.getByRole('button', { name: 'Submission history' }).click() + await expect(rejectedCard).toContainText('Rejected by Playwright E2E') + await rejectedCard.getByRole('link', { name: 'Edit and resubmit' }).click() + await expect(page).toHaveURL(/\/dashboard\/publish/) + await expect(page.getByText(new RegExp(`Resubmit .* v${firstPublish.version}`))).toBeVisible() const replacement = await publisherBuilder.publishSkill(namespace.slug, { name: skillName, @@ -74,8 +110,122 @@ test.describe('Rejected version replacement (Real API)', () => { expect(replacement.version).toBe(firstPublish.version) expect(replacementReviewId).not.toBe(rejectedReviewId) + const progressResponse = await page.request.get( + `/api/web/reviews/my-progress?q=${encodeURIComponent(replacement.slug)}&page=0&size=20`, + ) + expect(progressResponse.status()).toBe(200) + const progressBody = await progressResponse.json() as { + data: { + items: Array<{ latestStatus: string; attemptCount: number }> + total: number + statusCounts: { pending: number; approved: number; rejected: number } + } + } + expect(progressBody.data.total).toBe(1) + expect(progressBody.data.items).toHaveLength(1) + expect(progressBody.data.items[0]).toMatchObject({ latestStatus: 'PENDING', attemptCount: 2 }) + expect(progressBody.data.statusCounts).toEqual({ pending: 1, approved: 0, rejected: 0 }) + + const rejectedFilterResponse = await page.request.get( + `/api/web/reviews/my-progress?q=${encodeURIComponent(replacement.slug)}&status=REJECTED&page=0&size=20`, + ) + expect(rejectedFilterResponse.status()).toBe(200) + const rejectedFilterBody = await rejectedFilterResponse.json() as { + data: { items: unknown[]; total: number; statusCounts: { pending: number } } + } + expect(rejectedFilterBody.data.items).toEqual([]) + expect(rejectedFilterBody.data.total).toBe(0) + expect(rejectedFilterBody.data.statusCounts.pending).toBe(1) + + const missingSearchResponse = await page.request.get( + '/api/web/reviews/my-progress?q=definitely-missing-review-progress&page=0&size=20', + ) + expect(missingSearchResponse.status()).toBe(200) + const missingSearchBody = await missingSearchResponse.json() as { + data: { items: unknown[]; total: number; statusCounts: { pending: number; approved: number; rejected: number } } + } + expect(missingSearchBody.data.items).toEqual([]) + expect(missingSearchBody.data.total).toBe(0) + expect(missingSearchBody.data.statusCounts).toEqual({ pending: 0, approved: 0, rejected: 0 }) + + const outOfRangeResponse = await page.request.get( + `/api/web/reviews/my-progress?q=${encodeURIComponent(replacement.slug)}&page=99&size=1`, + ) + expect(outOfRangeResponse.status()).toBe(200) + const outOfRangeBody = await outOfRangeResponse.json() as { + data: { items: unknown[]; total: number; statusCounts: { pending: number } } + } + expect(outOfRangeBody.data.items).toEqual([]) + expect(outOfRangeBody.data.total).toBe(1) + expect(outOfRangeBody.data.statusCounts.pending).toBe(1) + + const maximumPageResponse = await page.request.get( + `/api/web/reviews/my-progress?q=${encodeURIComponent(replacement.slug)}&page=2147483647&size=100`, + ) + expect(maximumPageResponse.status()).toBe(200) + const maximumPageBody = await maximumPageResponse.json() as { + data: { items: unknown[]; total: number; statusCounts: { pending: number } } + } + expect(maximumPageBody.data.items).toEqual([]) + expect(maximumPageBody.data.total).toBe(1) + expect(maximumPageBody.data.statusCounts.pending).toBe(1) + + const attemptsResponse = await page.request.get( + `/api/web/reviews/my-progress/${replacementReviewId}/attempts`, + ) + expect(attemptsResponse.status()).toBe(200) + const attemptsBody = await attemptsResponse.json() as { + data: Array<{ id: number; status: string; skillVersionId: number | null }> + } + expect(attemptsBody.data.map((attempt) => attempt.id)).toEqual([ + replacementReviewId, + rejectedReviewId, + ]) + expect(attemptsBody.data.map((attempt) => attempt.status)).toEqual(['PENDING', 'REJECTED']) + expect(attemptsBody.data[1]?.skillVersionId).toBeNull() + + const reviewerAttemptsResponse = await adminPage.request.get( + `/api/web/reviews/${replacementReviewId}/attempts`, + ) + expect(reviewerAttemptsResponse.status()).toBe(200) + const reviewerAttemptsBody = await reviewerAttemptsResponse.json() as { + data: Array<{ id: number; status: string }> + } + expect(reviewerAttemptsBody.data.map((attempt) => attempt.id)).toEqual([ + replacementReviewId, + rejectedReviewId, + ]) + const replacedReviewResponse = await adminPage.request.get(`/api/web/reviews/${rejectedReviewId}`) - expect(replacedReviewResponse.status()).toBe(404) + expect(replacedReviewResponse.status()).toBe(200) + + await page.goto('/dashboard/review-progress') + await expect(page.getByRole('heading', { name: 'My Review Progress' })).toBeVisible() + await expect(page.getByRole('button', { name: /In review/ })).toContainText('1') + const progressCard = page.locator('article').filter({ hasText: replacement.slug }) + await expect(progressCard).toContainText('In review') + await expect(progressCard).toContainText('2 submissions') + await progressCard.getByRole('button', { name: 'Submission history' }).click() + await expect(progressCard).toContainText('Attempt 2') + await expect(progressCard).toContainText('Attempt 1') + await expect(progressCard).toContainText('Rejected by Playwright E2E') + await expect(progressCard).toContainText('Reviewed by') + await expect(progressCard.getByRole('link', { name: 'Edit and resubmit' })).toHaveCount(0) + await page.screenshot({ path: testInfo.outputPath('author-review-progress-desktop.png'), fullPage: true }) + + await page.setViewportSize({ width: 390, height: 844 }) + await expect(progressCard).toBeVisible() + await expect.poll(() => page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true) + await page.screenshot({ path: testInfo.outputPath('author-review-progress-mobile.png'), fullPage: true }) + + await adminPage.goto(`/dashboard/reviews/${replacementReviewId}`) + await expect(adminPage.getByRole('heading', { name: 'Submission History' })).toBeVisible() + await expect(adminPage.getByText('Attempt 2')).toBeVisible() + await expect(adminPage.getByText('Attempt 1')).toBeVisible() + expect(withoutKnownMetaCspWarning(consoleErrors)).toEqual([]) + expect(pageErrors).toEqual([]) + expect(withoutKnownMetaCspWarning(adminConsoleErrors)).toEqual([]) + expect(adminPageErrors).toEqual([]) } finally { await adminBuilder.cleanup() await adminContext.close() diff --git a/web/e2e/settings-pages.spec.ts b/web/e2e/settings-pages.spec.ts index 38f28760..f41f808b 100644 --- a/web/e2e/settings-pages.spec.ts +++ b/web/e2e/settings-pages.spec.ts @@ -1,13 +1,13 @@ import { expect, test } from '@playwright/test' import { setEnglishLocale } from './helpers/auth-fixtures' -import { createFreshSession } from './helpers/session' +import { registerSession } from './helpers/session' test.describe('Settings Pages (Real API)', () => { test.use({ baseURL: 'http://127.0.0.1:3000' }) test.beforeEach(async ({ page }, testInfo) => { await setEnglishLocale(page) - await createFreshSession(page, testInfo) + await registerSession(page, testInfo, { allowMockSession: false }) }) test('opens profile settings page', async ({ page }) => { diff --git a/web/e2e/theme-toggle.spec.ts b/web/e2e/theme-toggle.spec.ts new file mode 100644 index 00000000..e01105c8 --- /dev/null +++ b/web/e2e/theme-toggle.spec.ts @@ -0,0 +1,210 @@ +import { expect, test } from '@playwright/test' +import { setEnglishLocale } from './helpers/auth-fixtures' + +test.describe('Light and dark theme', () => { + test.beforeEach(async ({ page }) => { + await setEnglishLocale(page) + await page.context().setExtraHTTPHeaders({ 'X-Mock-User-Id': 'local-user' }) + await page.route('**/api/v1/auth/me', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + code: 0, + msg: 'success', + data: { + userId: 'theme-layout-user', + displayName: 'Theme Layout User', + avatarUrl: 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///ywAAAAAAQABAAACAUwAOw==', + platformRoles: [], + oauthProvider: 'local', + canChangePassword: true, + }, + timestamp: '2026-09-01T00:00:00Z', + requestId: 'theme-auth-fixture', + }), + }) + }) + await page.route('**/api/web/me/namespaces', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + code: 0, + msg: 'success', + data: [], + timestamp: '2026-09-01T00:00:00Z', + requestId: 'theme-namespace-fixture', + }), + }) + }) + await page.route('**/api/web/notifications/unread-count', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + code: 0, + msg: 'success', + data: { count: 1 }, + timestamp: '2026-09-01T00:00:00Z', + requestId: 'theme-unread-fixture', + }), + }) + }) + await page.route('**/api/web/me/stars?*', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + code: 0, + msg: 'success', + data: { items: [], total: 0, page: 0, size: 100 }, + timestamp: '2026-09-01T00:00:00Z', + requestId: 'theme-stars-fixture', + }), + }) + }) + await page.addInitScript(() => { + const observedWindow = window as Window & { __themeAtFirstReactContent?: boolean } + const observer = new MutationObserver(() => { + const root = document.querySelector('#root') + if (root?.childElementCount) { + observedWindow.__themeAtFirstReactContent = document.documentElement.classList.contains('dark') + observer.disconnect() + } + }) + observer.observe(document, { childList: true, subtree: true }) + if (!window.sessionStorage.getItem('theme-test-initialized')) { + window.localStorage.removeItem('skillhub-theme') + window.sessionStorage.setItem('theme-test-initialized', 'true') + } + }) + }) + + test('switches themes and restores only the browser-local selection', async ({ page }, testInfo) => { + const consoleErrors: string[] = [] + const pageErrors: string[] = [] + page.on('console', (message) => { + if (message.type() === 'error') consoleErrors.push(message.text()) + }) + page.on('pageerror', (error) => pageErrors.push(error.stack ?? error.message)) + + await page.route('**/api/web/notifications?*', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + code: 0, + msg: 'success', + data: { + items: [{ + id: 9001, + category: 'REVIEW', + eventType: 'REVIEW_SUBMITTED', + title: 'Theme notification fixture', + bodyJson: JSON.stringify({ skillName: 'Theme preview', version: '1.0.0' }), + targetRoute: '/search', + status: 'UNREAD', + createdAt: '2026-09-01T00:00:00Z', + }], + total: 1, + page: 0, + size: 5, + }, + timestamp: '2026-09-01T00:00:00Z', + requestId: 'theme-notification-fixture', + }), + }) + }) + + await page.goto('/') + await expect(page.locator('html')).not.toHaveClass(/dark/) + const header = page.locator('header') + const lightHeaderBackground = await header.evaluate((element) => getComputedStyle(element).backgroundColor) + + const themeSwitch = page.getByRole('switch', { name: 'Dark theme' }) + await expect(themeSwitch).toHaveAttribute('aria-checked', 'false') + await themeSwitch.click() + await expect(page.locator('html')).toHaveClass(/dark/) + await expect(themeSwitch).toHaveAttribute('aria-checked', 'true') + await expect.poll(() => header.evaluate((element) => getComputedStyle(element).backgroundColor)) + .not.toBe(lightHeaderBackground) + await expect.poll(() => page.evaluate(() => window.localStorage.getItem('skillhub-theme'))).toBe('dark') + const destructiveContrast = await page.evaluate(() => { + const probe = document.createElement('button') + probe.className = 'bg-destructive text-destructive-foreground' + probe.textContent = 'Destructive contrast probe' + document.body.append(probe) + const styles = getComputedStyle(probe) + + const luminance = (color: string) => { + const channels = color.match(/[\d.]+/g)?.slice(0, 3).map(Number) + if (!channels || channels.length !== 3) { + throw new Error(`Unable to parse computed color: ${color}`) + } + const linear = channels.map((channel) => { + const normalized = channel / 255 + return normalized <= 0.04045 + ? normalized / 12.92 + : ((normalized + 0.055) / 1.055) ** 2.4 + }) + return 0.2126 * linear[0] + 0.7152 * linear[1] + 0.0722 * linear[2] + } + + const background = luminance(styles.backgroundColor) + const foreground = luminance(styles.color) + probe.remove() + return (Math.max(background, foreground) + 0.05) / (Math.min(background, foreground) + 0.05) + }) + expect(destructiveContrast).toBeGreaterThanOrEqual(4.5) + + await page.reload() + await expect(page.locator('html')).toHaveClass(/dark/) + await expect(page.getByRole('switch', { name: 'Dark theme' })).toHaveAttribute('aria-checked', 'true') + await expect(page.getByRole('heading', { name: 'SkillHub', exact: true })).toBeVisible() + await expect.poll(() => page.evaluate(() => ( + window as Window & { __themeAtFirstReactContent?: boolean } + ).__themeAtFirstReactContent)).toBe(true) + + await page.getByRole('link', { name: 'Search', exact: true }).first().click() + await expect(page).toHaveURL(/\/search(?:\?|$)/) + await expect(page.locator('html')).toHaveClass(/dark/) + await expect(page.getByPlaceholder('Search skills...')).toBeVisible() + await expect.poll(() => page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true) + await page.screenshot({ path: testInfo.outputPath('dark-desktop.png'), fullPage: true }) + + const notificationButton = page.getByRole('button', { name: 'Notifications' }) + await notificationButton.click() + await expect(page.getByText('Notifications', { exact: true })).toBeVisible() + const firstNotification = page.getByRole('link').filter({ hasText: 'Review submitted' }) + await expect(firstNotification).toBeVisible() + const backgroundBeforeHover = await firstNotification.evaluate((element) => getComputedStyle(element).backgroundColor) + await firstNotification.hover() + await expect.poll(() => firstNotification.evaluate((element) => getComputedStyle(element).backgroundColor)) + .not.toBe(backgroundBeforeHover) + await page.screenshot({ path: testInfo.outputPath('dark-notifications.png'), fullPage: true }) + await notificationButton.click() + + await page.setViewportSize({ width: 390, height: 844 }) + await expect(page.getByRole('switch', { name: 'Dark theme' })).toBeVisible() + await expect.poll(() => page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true) + await page.screenshot({ path: testInfo.outputPath('dark-mobile.png'), fullPage: true }) + + await page.setViewportSize({ width: 320, height: 568 }) + const headerControls = page.locator('header > div') + const [headerBox, controlsBox] = await Promise.all([header.boundingBox(), headerControls.boundingBox()]) + expect(headerBox).not.toBeNull() + expect(controlsBox).not.toBeNull() + expect((controlsBox?.x ?? 0) + (controlsBox?.width ?? 0)).toBeLessThanOrEqual( + (headerBox?.x ?? 0) + (headerBox?.width ?? 0), + ) + await expect.poll(() => page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true) + await page.screenshot({ path: testInfo.outputPath('dark-mobile-320.png'), fullPage: true }) + + const unexpectedConsoleErrors = consoleErrors.filter((message) => ( + !message.includes("frame-ancestors' is ignored when delivered via a element") + )) + expect(unexpectedConsoleErrors).toEqual([]) + expect(pageErrors).toEqual([]) + }) +}) diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 5e4c7756..c503257f 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -15,6 +15,7 @@ import type { MergeInitiateResponse, MergeVerifyRequest, ReviewSkillDetail, + ReviewProgressPage, ReviewTask, PromotionSortBy, PromotionSortDirection, @@ -882,6 +883,25 @@ export const reviewApi = { return fetchJson(`${WEB_API_PREFIX}/reviews/${id}`) }, + async listMyProgress(params: { status?: string; q?: string; page?: number; size?: number }) { + const searchParams = new URLSearchParams() + if (params.status) searchParams.set('status', params.status) + if (params.q) searchParams.set('q', params.q) + searchParams.set('page', String(params.page ?? 0)) + searchParams.set('size', String(params.size ?? 20)) + return fetchJson( + `${WEB_API_PREFIX}/reviews/my-progress?${searchParams.toString()}`, + ) + }, + + async listMyAttempts(reviewTaskId: number): Promise { + return fetchJson(`${WEB_API_PREFIX}/reviews/my-progress/${reviewTaskId}/attempts`) + }, + + async listAttempts(reviewTaskId: number): Promise { + return fetchJson(`${WEB_API_PREFIX}/reviews/${reviewTaskId}/attempts`) + }, + async getSkillDetail(id: number): Promise { return fetchJson(`${WEB_API_PREFIX}/reviews/${id}/skill-detail`) }, diff --git a/web/src/api/generated/schema.d.ts b/web/src/api/generated/schema.d.ts index 69d55df9..5fc821ed 100644 --- a/web/src/api/generated/schema.d.ts +++ b/web/src/api/generated/schema.d.ts @@ -68,6 +68,38 @@ export interface paths { patch?: never; trace?: never; }; + "/api/web/skills/{skillId}/reviews/me": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["getMine"]; + put: operations["upsert"]; + post?: never; + delete: operations["clear"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/skills/{skillId}/reviews/me": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["getMine_1"]; + put: operations["upsert_1"]; + post?: never; + delete: operations["clear_1"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/web/skills/{skillId}/rating": { parameters: { query?: never; @@ -1195,7 +1227,7 @@ export interface paths { path?: never; cookie?: never; }; - get: operations["list_2"]; + get: operations["list_4"]; put?: never; post: operations["create"]; delete?: never; @@ -1236,6 +1268,22 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v1/skills/{skillId}/versions/{versionId}/security-audit/retry": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["retrySecurityScan"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/skills/{canonicalSlug}/undelete": { parameters: { query?: never; @@ -1540,6 +1588,38 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v1/admin/skill-reviews/{reviewId}/restore": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["restore"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/admin/skill-reviews/{reviewId}/hide": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["hide"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/admin/skill-reports/{reportId}/resolve": { parameters: { query?: never; @@ -1860,6 +1940,38 @@ export interface paths { patch?: never; trace?: never; }; + "/api/web/skills/{skillId}/reviews": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["list"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/skills/{skillId}/reviews": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["list_1"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/web/skills/{namespace}/{slug}/versions/{version}/files": { parameters: { query?: never; @@ -2404,6 +2516,38 @@ export interface paths { patch?: never; trace?: never; }; + "/api/web/reviews/{id}/attempts": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["listReviewAttempts"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/reviews/{id}/attempts": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["listReviewAttempts_1"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/reviews/{id}": { parameters: { query?: never; @@ -2500,6 +2644,70 @@ export interface paths { patch?: never; trace?: never; }; + "/api/web/reviews/my-progress/{id}/attempts": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["listMyAttempts"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/reviews/my-progress/{id}/attempts": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["listMyAttempts_1"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/web/reviews/my-progress": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["listMyProgress"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/reviews/my-progress": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["listMyProgress_1"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/web/promotions/{id}": { parameters: { query?: never; @@ -2596,38 +2804,6 @@ export interface paths { patch?: never; trace?: never; }; - "/api/v1/notifications/sse": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get: operations["sse"]; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/api/web/notifications/sse": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get: operations["sse_1"]; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; "/api/web/notifications": { parameters: { query?: never; @@ -2635,7 +2811,7 @@ export interface paths { path?: never; cookie?: never; }; - get: operations["list"]; + get: operations["list_2"]; put?: never; post?: never; delete?: never; @@ -2651,7 +2827,7 @@ export interface paths { path?: never; cookie?: never; }; - get: operations["list_1"]; + get: operations["list_3"]; put?: never; post?: never; delete?: never; @@ -3211,7 +3387,7 @@ export interface paths { path?: never; cookie?: never; }; - get: operations["list_3"]; + get: operations["list_5"]; put?: never; post?: never; delete?: never; @@ -3553,6 +3729,35 @@ export interface components { timestamp?: string; requestId?: string; }; + SkillReviewRequest: { + /** Format: int32 */ + score: number; + reviewText: string; + }; + ApiResponseSkillReviewMeResponse: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["SkillReviewMeResponse"]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; + SkillReviewMeResponse: { + rated?: boolean; + /** Format: int32 */ + score?: number; + reviewed?: boolean; + /** Format: int64 */ + reviewId?: number; + reviewText?: string; + status?: string; + moderationReason?: string; + /** Format: date-time */ + createdAt?: string; + /** Format: date-time */ + updatedAt?: string; + }; SkillRatingRequest: { /** Format: int32 */ score: number; @@ -4145,6 +4350,35 @@ export interface components { timestamp?: string; requestId?: string; }; + ApiResponseSkillReviewResponse: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["SkillReviewResponse"]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; + SkillReviewResponse: { + /** Format: int64 */ + id?: number; + userId?: string; + displayName?: string; + avatarUrl?: string; + /** Format: int32 */ + score?: number; + reviewText?: string; + status?: string; + authoredByViewer?: boolean; + moderationReason?: string; + /** Format: date-time */ + createdAt?: string; + /** Format: date-time */ + updatedAt?: string; + }; + SkillReviewModerationRequest: { + reason?: string; + }; AdminSkillReportActionRequest: { comment?: string; disposition?: string; @@ -4382,6 +4616,24 @@ export interface components { timestamp?: string; requestId?: string; }; + ApiResponsePageResponseSkillReviewResponse: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["PageResponseSkillReviewResponse"]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; + PageResponseSkillReviewResponse: { + items?: components["schemas"]["SkillReviewResponse"][]; + /** Format: int64 */ + total?: number; + /** Format: int32 */ + page?: number; + /** Format: int32 */ + size?: number; + }; ApiResponseSkillRatingStatusResponse: { /** Format: int32 */ code?: number; @@ -4629,6 +4881,15 @@ export interface components { downloadUrl?: string; activeVersion?: string; }; + ApiResponseListReviewTaskResponse: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["ReviewTaskResponse"][]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; ApiResponsePageResponseReviewTaskResponse: { /** Format: int32 */ code?: number; @@ -4647,6 +4908,50 @@ export interface components { /** Format: int32 */ size?: number; }; + ApiResponseReviewProgressPageResponse: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["ReviewProgressPageResponse"]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; + ReviewProgressPageResponse: { + items?: components["schemas"]["ReviewProgressResponse"][]; + /** Format: int64 */ + total?: number; + /** Format: int32 */ + page?: number; + /** Format: int32 */ + size?: number; + statusCounts?: components["schemas"]["ReviewProgressStatusCounts"]; + }; + ReviewProgressResponse: { + /** Format: int64 */ + latestReviewTaskId?: number; + /** Format: int64 */ + skillId?: number; + namespace?: string; + skillSlug?: string; + skillVersion?: string; + latestStatus?: string; + latestReviewComment?: string; + /** Format: date-time */ + latestSubmittedAt?: string; + /** Format: date-time */ + latestReviewedAt?: string; + /** Format: int64 */ + attemptCount?: number; + }; + ReviewProgressStatusCounts: { + /** Format: int64 */ + pending?: number; + /** Format: int64 */ + approved?: number; + /** Format: int64 */ + rejected?: number; + }; ApiResponsePageResponsePromotionResponseDto: { /** Format: int32 */ code?: number; @@ -4676,10 +4981,6 @@ export interface components { timestamp?: string; requestId?: string; }; - SseEmitter: { - /** Format: int64 */ - timeout?: number; - }; ApiResponsePageResponseNotificationResponse: { /** Format: int32 */ code?: number; @@ -5024,6 +5325,7 @@ export interface components { findings?: components["schemas"]["SecurityFinding"][]; /** Format: double */ scanDurationSeconds?: number; + failureReason?: string; /** Format: date-time */ scannedAt?: string; /** Format: date-time */ @@ -5697,6 +5999,146 @@ export interface operations { }; }; }; + getMine: { + parameters: { + query?: never; + header?: never; + path: { + skillId: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseSkillReviewMeResponse"]; + }; + }; + }; + }; + upsert: { + parameters: { + query?: never; + header?: never; + path: { + skillId: number; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["SkillReviewRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseSkillReviewMeResponse"]; + }; + }; + }; + }; + clear: { + parameters: { + query?: never; + header?: never; + path: { + skillId: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseSkillReviewMeResponse"]; + }; + }; + }; + }; + getMine_1: { + parameters: { + query?: never; + header?: never; + path: { + skillId: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseSkillReviewMeResponse"]; + }; + }; + }; + }; + upsert_1: { + parameters: { + query?: never; + header?: never; + path: { + skillId: number; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["SkillReviewRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseSkillReviewMeResponse"]; + }; + }; + }; + }; + clear_1: { + parameters: { + query?: never; + header?: never; + path: { + skillId: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseSkillReviewMeResponse"]; + }; + }; + }; + }; getUserRating: { parameters: { query?: never; @@ -7950,7 +8392,7 @@ export interface operations { }; }; }; - list_2: { + list_4: { parameters: { query?: { page?: number; @@ -8091,6 +8533,29 @@ export interface operations { }; }; }; + retrySecurityScan: { + parameters: { + query?: never; + header?: never; + path: { + skillId: number; + versionId: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseSkillLifecycleMutationResponse"]; + }; + }; + }; + }; undeleteSkill: { parameters: { query?: never; @@ -8541,6 +9006,54 @@ export interface operations { }; }; }; + restore: { + parameters: { + query?: never; + header?: never; + path: { + reviewId: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseSkillReviewResponse"]; + }; + }; + }; + }; + hide: { + parameters: { + query?: never; + header?: never; + path: { + reviewId: number; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["SkillReviewModerationRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseSkillReviewResponse"]; + }; + }; + }; + }; resolveReport: { parameters: { query?: never; @@ -9109,6 +9622,56 @@ export interface operations { }; }; }; + list: { + parameters: { + query?: { + page?: number; + size?: number; + }; + header?: never; + path: { + skillId: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponsePageResponseSkillReviewResponse"]; + }; + }; + }; + }; + list_1: { + parameters: { + query?: { + page?: number; + size?: number; + }; + header?: never; + path: { + skillId: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponsePageResponseSkillReviewResponse"]; + }; + }; + }; + }; listFiles: { parameters: { query?: never; @@ -10029,6 +10592,50 @@ export interface operations { }; }; }; + listReviewAttempts: { + parameters: { + query?: never; + header?: never; + path: { + id: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseListReviewTaskResponse"]; + }; + }; + }; + }; + listReviewAttempts_1: { + parameters: { + query?: never; + header?: never; + path: { + id: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseListReviewTaskResponse"]; + }; + }; + }; + }; getReviewDetail: { parameters: { query?: never; @@ -10167,6 +10774,100 @@ export interface operations { }; }; }; + listMyAttempts: { + parameters: { + query?: never; + header?: never; + path: { + id: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseListReviewTaskResponse"]; + }; + }; + }; + }; + listMyAttempts_1: { + parameters: { + query?: never; + header?: never; + path: { + id: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseListReviewTaskResponse"]; + }; + }; + }; + }; + listMyProgress: { + parameters: { + query?: { + status?: string; + q?: string; + page?: number; + size?: number; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseReviewProgressPageResponse"]; + }; + }; + }; + }; + listMyProgress_1: { + parameters: { + query?: { + status?: string; + q?: string; + page?: number; + size?: number; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseReviewProgressPageResponse"]; + }; + }; + }; + }; getPromotionDetail: { parameters: { query?: never; @@ -10297,47 +10998,7 @@ export interface operations { }; }; }; - sse: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description OK */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "text/event-stream": components["schemas"]["SseEmitter"]; - }; - }; - }; - }; - sse_1: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description OK */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "text/event-stream": components["schemas"]["SseEmitter"]; - }; - }; - }; - }; - list: { + list_2: { parameters: { query?: { category?: string; @@ -10361,7 +11022,7 @@ export interface operations { }; }; }; - list_1: { + list_3: { parameters: { query?: { category?: string; @@ -11176,7 +11837,7 @@ export interface operations { }; }; }; - list_3: { + list_5: { parameters: { query?: { status?: string; diff --git a/web/src/api/types.ts b/web/src/api/types.ts index e5d62e12..4db8e8db 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -427,7 +427,7 @@ export interface SkillDeleteResult { export interface ReviewTask { id: number - skillVersionId: number + skillVersionId: number | null namespace: string skillSlug: string version: string @@ -441,6 +441,33 @@ export interface ReviewTask { reviewedAt?: string } +export interface ReviewProgress { + latestReviewTaskId: number + skillId: number + namespace: string + skillSlug: string + skillVersion: string + latestStatus: 'PENDING' | 'APPROVED' | 'REJECTED' + latestReviewComment?: string + latestSubmittedAt: string + latestReviewedAt?: string + attemptCount: number +} + +export interface ReviewProgressStatusCounts { + pending: number + approved: number + rejected: number +} + +export interface ReviewProgressPage { + items: ReviewProgress[] + total: number + page: number + size: number + statusCounts: ReviewProgressStatusCounts +} + export interface ReviewSkillDetail { skill: SkillDetail versions: SkillVersion[] diff --git a/web/src/app/layout-header-style.test.ts b/web/src/app/layout-header-style.test.ts index 25337244..680aa658 100644 --- a/web/src/app/layout-header-style.test.ts +++ b/web/src/app/layout-header-style.test.ts @@ -3,7 +3,11 @@ import { APP_HEADER_ELEVATED_CLASS_NAME, getAppHeaderClassName } from './layout- describe('getAppHeaderClassName', () => { it('keeps the header flat before the page starts scrolling', () => { - expect(getAppHeaderClassName(false)).not.toContain(APP_HEADER_ELEVATED_CLASS_NAME) + const className = getAppHeaderClassName(false) + + expect(className).not.toContain(APP_HEADER_ELEVATED_CLASS_NAME) + expect(className).toContain('bg-background/90') + expect(className).not.toContain('bg-white') }) it('adds a subtle drop shadow after the header becomes sticky', () => { diff --git a/web/src/app/layout-header-style.ts b/web/src/app/layout-header-style.ts index 3ae7e8fc..49288285 100644 --- a/web/src/app/layout-header-style.ts +++ b/web/src/app/layout-header-style.ts @@ -1,9 +1,10 @@ import { cn } from '@/shared/lib/utils' export const APP_HEADER_BASE_CLASS_NAME = - 'sticky top-0 z-50 flex items-center justify-between border-b bg-white px-6 py-4 transition-shadow duration-200 md:px-12' + 'sticky top-0 z-50 flex items-center justify-between border-b border-border/70 bg-background/90 px-4 py-4 backdrop-blur-xl transition-[background-color,border-color,box-shadow] duration-200 supports-[backdrop-filter]:bg-background/80 sm:px-6 md:px-12' -export const APP_HEADER_ELEVATED_CLASS_NAME = 'shadow-[0_10px_24px_-20px_rgba(15,23,42,0.32)]' +export const APP_HEADER_ELEVATED_CLASS_NAME = + 'shadow-[0_12px_30px_-24px_hsl(var(--foreground)/0.45)]' export function getAppHeaderClassName(isElevated: boolean): string { return cn(APP_HEADER_BASE_CLASS_NAME, isElevated && APP_HEADER_ELEVATED_CLASS_NAME) diff --git a/web/src/app/layout.tsx b/web/src/app/layout.tsx index 7a52720b..7d5000a8 100644 --- a/web/src/app/layout.tsx +++ b/web/src/app/layout.tsx @@ -3,6 +3,7 @@ import { Outlet, Link, useRouterState } from '@tanstack/react-router' import { useTranslation } from 'react-i18next' import { useAuth } from '@/features/auth/use-auth' import { LanguageSwitcher } from '@/shared/components/language-switcher' +import { ThemeToggle } from '@/shared/components/theme-toggle' import { UserMenu } from '@/shared/components/user-menu' import { NotificationBell } from '@/features/notification/notification-bell' import { dismissOpenOverlays } from '@/shared/lib/dismiss-open-overlays' @@ -82,7 +83,7 @@ export function Layout() {
@@ -115,7 +116,8 @@ export function Layout() { })} -
+
+ {user && } {isLoading ? null : user ? ( @@ -149,7 +151,7 @@ export function Layout() { {/* Footer */} -