Merge branch 'main' into fix/runtime-auth-env-vars

This commit is contained in:
wowo 2026-04-17 10:17:32 +08:00
commit d0f0462b58
144 changed files with 6925 additions and 575 deletions

View file

@ -80,3 +80,16 @@ DEVICE_AUTH_VERIFICATION_URI=
# Leave both empty if you are not enabling GitHub login yet.
OAUTH2_GITHUB_CLIENT_ID=
OAUTH2_GITHUB_CLIENT_SECRET=
# SMTP configuration for password reset verification emails.
SPRING_MAIL_HOST=smtp.example.com
SPRING_MAIL_PORT=587
SPRING_MAIL_USERNAME=TODO_fill_smtp_username
SPRING_MAIL_PASSWORD=TODO_fill_smtp_password
SPRING_MAIL_SMTP_AUTH=true
SPRING_MAIL_SMTP_STARTTLS_ENABLE=true
SPRING_MAIL_PROPERTIES_MAIL_SMTP_SSL_ENABLE=false
SPRING_MAIL_PROPERTIES_MAIL_SMTP_SSL_TRUST=
SKILLHUB_AUTH_PASSWORD_RESET_CODE_EXPIRY=PT10M
SKILLHUB_AUTH_PASSWORD_RESET_FROM_ADDRESS=noreply@example.com
SKILLHUB_AUTH_PASSWORD_RESET_FROM_NAME=SkillHub

View file

@ -62,6 +62,19 @@ SKILLHUB_AUTH_DIRECT_ENABLED=false
SKILLHUB_WEB_AUTH_DIRECT_ENABLED=false
SKILLHUB_WEB_AUTH_DIRECT_PROVIDER=
# SMTP configuration for password reset verification emails.
SPRING_MAIL_HOST=
SPRING_MAIL_PORT=587
SPRING_MAIL_USERNAME=
SPRING_MAIL_PASSWORD=
SPRING_MAIL_SMTP_AUTH=true
SPRING_MAIL_SMTP_STARTTLS_ENABLE=true
SPRING_MAIL_PROPERTIES_MAIL_SMTP_SSL_ENABLE=false
SPRING_MAIL_PROPERTIES_MAIL_SMTP_SSL_TRUST=
SKILLHUB_AUTH_PASSWORD_RESET_CODE_EXPIRY=PT10M
SKILLHUB_AUTH_PASSWORD_RESET_FROM_ADDRESS=noreply@example.com
SKILLHUB_AUTH_PASSWORD_RESET_FROM_NAME=SkillHub
# Security scanner is enabled by default. Set to false to disable scanning.
SKILLHUB_SECURITY_SCANNER_ENABLED=true

View file

@ -8,6 +8,7 @@
[![DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/iflytek/skillhub)
[![Docs](https://img.shields.io/badge/docs-zread.ai-4A90E2?logo=gitbook&logoColor=white)](https://zread.ai/iflytek/skillhub)
[![Discord](https://img.shields.io/badge/discord-join-5865F2?logo=discord&logoColor=white)](https://discord.gg/qHYvtDNPHS)
[![License](https://img.shields.io/badge/license-Apache%202.0-blue.svg)](./LICENSE)
[![Build](https://github.com/iflytek/skillhub/actions/workflows/publish-images.yml/badge.svg)](https://github.com/iflytek/skillhub/actions/workflows/publish-images.yml)
[![Docker](https://img.shields.io/badge/docker-ghcr.io-2496ED?logo=docker&logoColor=white)](https://ghcr.io/iflytek/skillhub)
@ -95,7 +96,7 @@ The `--public-url` parameter sets the public access URL for your SkillHub instan
**For users in China (Aliyun mirror):**
```bash
curl -fsSL https://imageless.oss-cn-beijing.aliyuncs.com/runtime.sh | sh -s -- up --aliyun --public-url https://skillhub.your-company.com
curl -fsSL https://imageless.oss-cn-beijing.aliyuncs.com/runtime.sh | sh -s -- up --aliyun --public-url https://skillhub.your-company.com --version latest
```
If deployment runs into problems, clear the existing runtime home and retry.
@ -195,7 +196,7 @@ Published images target both `linux/amd64` and `linux/arm64`.
curl -fsSL https://imageless.oss-cn-beijing.aliyuncs.com/runtime.sh | sh -s -- up --public-url https://skillhub.your-company.com
# Aliyun mirror (recommended for users in China)
curl -fsSL https://imageless.oss-cn-beijing.aliyuncs.com/runtime.sh | sh -s -- up --aliyun --public-url https://skillhub.your-company.com
curl -fsSL https://imageless.oss-cn-beijing.aliyuncs.com/runtime.sh | sh -s -- up --aliyun --public-url https://skillhub.your-company.com --version latest
```
**Deployment parameters:**
@ -222,6 +223,7 @@ cp .env.release.example .env.release
Recommended image tags:
- `SKILLHUB_VERSION=latest` for the latest stable release (default)
- `SKILLHUB_VERSION=edge` for the latest `main` build
- `SKILLHUB_VERSION=vX.Y.Z` for a fixed release
@ -396,10 +398,16 @@ npx clawhub search email
npx clawhub install my-skill
npx clawhub install my-namespace--my-skill
# Publish a skill
npx clawhub publish ./my-skill
# Publish to global namespace
npx clawhub publish ./my-skill --slug my-skill --version 1.0.0
# Publish to a team namespace such as my-space
npx clawhub publish ./my-skill --slug my-space--my-skill --version 1.0.0
```
`my-space--my-skill` is the canonical compat slug. SkillHub parses it as
namespace `my-space` plus skill slug `my-skill`.
> 💡 **Tip**: The above commands are not only applicable to OpenClaw, but also to other CLI Coding Agents or Agent assistants by specifying the installation directory (`--dir`). For example: `npx clawhub --dir ~/.claude/skills install my-skill`
📖 **[Complete OpenClaw Integration Guide →](./docs/openclaw-integration.md)**
@ -434,6 +442,7 @@ what you'd like to change.
- 💬 **Community Discussion**: [GitHub Discussions](https://github.com/iflytek/skillhub/discussions)
- 🐛 **Bug Reports**: [Issues](https://github.com/iflytek/skillhub/issues)
- 👾 **Discord**: [Join our Server](https://discord.gg/qHYvtDNPHS)
- 👥 **WeChat Work Group**:
![WeChat Work Group](https://github.com/iflytek/astron-agent/raw/main/docs/imgs/WeCom_Group.png)

View file

@ -7,6 +7,7 @@
<div align="center">
[![文档](https://img.shields.io/badge/docs-zread.ai-4A90E2?logo=gitbook&logoColor=white)](https://zread.ai/iflytek/skillhub)
[![Discord](https://img.shields.io/badge/discord-join-5865F2?logo=discord&logoColor=white)](https://discord.gg/qHYvtDNPHS)
[![许可证](https://img.shields.io/badge/license-Apache%202.0-blue.svg)](./LICENSE)
[![构建](https://github.com/iflytek/skillhub/actions/workflows/publish-images.yml/badge.svg)](https://github.com/iflytek/skillhub/actions/workflows/publish-images.yml)
[![Docker](https://img.shields.io/badge/docker-ghcr.io-2496ED?logo=docker&logoColor=white)](https://ghcr.io/iflytek/skillhub)
@ -67,7 +68,7 @@ curl -fsSL https://imageless.oss-cn-beijing.aliyuncs.com/runtime.sh | sh -s -- u
**国内用户(阿里云镜像):**
```bash
curl -fsSL https://imageless.oss-cn-beijing.aliyuncs.com/runtime.sh | sh -s -- up --aliyun --public-url https://skillhub.your-company.com
curl -fsSL https://imageless.oss-cn-beijing.aliyuncs.com/runtime.sh | sh -s -- up --aliyun --public-url https://skillhub.your-company.com --version latest
```
如果部署遇到问题,请清除现有的运行时目录并重试。
@ -177,7 +178,7 @@ skillhub/
curl -fsSL https://imageless.oss-cn-beijing.aliyuncs.com/runtime.sh | sh -s -- up --public-url https://skillhub.your-company.com
# 阿里云镜像(国内推荐)
curl -fsSL https://imageless.oss-cn-beijing.aliyuncs.com/runtime.sh | sh -s -- up --aliyun --public-url https://skillhub.your-company.com
curl -fsSL https://imageless.oss-cn-beijing.aliyuncs.com/runtime.sh | sh -s -- up --aliyun --public-url https://skillhub.your-company.com --version latest
```
### 配置参数说明
@ -331,10 +332,16 @@ npx clawhub search email
npx clawhub install my-skill
npx clawhub install my-namespace--my-skill
# 发布技能
npx clawhub publish ./my-skill
# 发布到 global 空间
npx clawhub publish ./my-skill --slug my-skill --version 1.0.0
# 发布到如 my-space 这样的团队空间
npx clawhub publish ./my-skill --slug my-space--my-skill --version 1.0.0
```
其中 `my-space--my-skill` 是兼容层使用的 canonical slugSkillHub 会将其解析为
namespace `my-space` 和 skill slug `my-skill`
> 💡 **提示**:上述命令不仅适用于 OpenClaw通过指定安装目录`--dir`),也可适用于其他的 CLI Coding Agent 或 Agent 助手。例如:`npx clawhub --dir ~/.claude/skills install my-skill`
📖 **[完整 OpenClaw 集成指南 →](./docs/openclaw-integration.md)**
@ -367,6 +374,7 @@ npx clawhub publish ./my-skill
- 💬 **社区讨论**[GitHub Discussions](https://github.com/iflytek/skillhub/discussions)
- 🐛 **Bug 报告**[Issues](https://github.com/iflytek/skillhub/issues)
- 👾 **Discord**[加入我们的服务器](https://discord.gg/qHYvtDNPHS)
- 👥 **企业微信群**
![企业微信群](https://github.com/iflytek/astron-agent/raw/main/docs/imgs/WeCom_Group.png)

View file

@ -81,6 +81,17 @@ services:
BOOTSTRAP_ADMIN_EMAIL: ${BOOTSTRAP_ADMIN_EMAIL:-admin@skillhub.local}
OAUTH2_GITHUB_CLIENT_ID: ${OAUTH2_GITHUB_CLIENT_ID:-local-placeholder}
OAUTH2_GITHUB_CLIENT_SECRET: ${OAUTH2_GITHUB_CLIENT_SECRET:-local-placeholder}
SPRING_MAIL_HOST: ${SPRING_MAIL_HOST:-}
SPRING_MAIL_PORT: ${SPRING_MAIL_PORT:-25}
SPRING_MAIL_USERNAME: ${SPRING_MAIL_USERNAME:-}
SPRING_MAIL_PASSWORD: ${SPRING_MAIL_PASSWORD:-}
SPRING_MAIL_SMTP_AUTH: ${SPRING_MAIL_SMTP_AUTH:-false}
SPRING_MAIL_SMTP_STARTTLS_ENABLE: ${SPRING_MAIL_SMTP_STARTTLS_ENABLE:-false}
SPRING_MAIL_PROPERTIES_MAIL_SMTP_SSL_ENABLE: ${SPRING_MAIL_PROPERTIES_MAIL_SMTP_SSL_ENABLE:-false}
SPRING_MAIL_PROPERTIES_MAIL_SMTP_SSL_TRUST: ${SPRING_MAIL_PROPERTIES_MAIL_SMTP_SSL_TRUST:-}
SKILLHUB_AUTH_PASSWORD_RESET_CODE_EXPIRY: ${SKILLHUB_AUTH_PASSWORD_RESET_CODE_EXPIRY:-PT10M}
SKILLHUB_AUTH_PASSWORD_RESET_FROM_ADDRESS: ${SKILLHUB_AUTH_PASSWORD_RESET_FROM_ADDRESS:-noreply@skillhub.local}
SKILLHUB_AUTH_PASSWORD_RESET_FROM_NAME: ${SKILLHUB_AUTH_PASSWORD_RESET_FROM_NAME:-SkillHub}
volumes:
- skillhub_storage:/var/lib/skillhub/storage
depends_on:

View file

@ -32,7 +32,7 @@ services:
redis:
image: ${REDIS_IMAGE:-redis:7-alpine}
ports:
- "6379:6379"
- "127.0.0.1:6379:6379"
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s

View file

@ -67,7 +67,7 @@ my-skill/
校验规则:
- 根目录必须包含 `SKILL.md`
- 文件类型白名单:`.md`, `.txt`, `.json`, `.yaml`, `.yml`, `.js`, `.ts`, `.py`, `.sh`, `.png`, `.jpg`, `.svg`
- 文件类型白名单:`.md`, `.txt`, `.json`, `.yaml`, `.yml`, `.js`, `.cjs`, `.mjs`, `.ts`, `.py`, `.sh`, `.png`, `.jpg`, `.svg`
- 单文件大小限制1MB可配置
- 总包大小限制10MB可配置
- 文件数量限制100 个(可配置)

View file

@ -195,6 +195,7 @@ docker compose --env-file .env.release -f compose.release.yml up -d
- 外部对象存储通过 `SKILLHUB_STORAGE_S3_*` 注入
- 前端反代和运行时 API 地址通过 `SKILLHUB_API_UPSTREAM` / `SKILLHUB_WEB_API_BASE_URL` 注入
- 如果要开放真实登录,再补充 `OAUTH2_GITHUB_CLIENT_ID` / `OAUTH2_GITHUB_CLIENT_SECRET`
- 如果要启用密码重置验证码邮件,参见:`docs/19-smtp-password-reset-email-setup.md`
## 8 裸金属上线清单

View file

@ -0,0 +1,317 @@
# SkillHub SMTP 邮箱配置指南(验证码邮件)
本文说明如何为 SkillHub 配置 SMTP用于发送“密码重置验证码”邮件。
适用场景:
- 生产/预发布环境(`compose.release.yml` + `.env.release`
- 本地联调环境(直接注入后端环境变量)
补充说明:
- SMTP 本质是邮件传输协议,不是单一厂商产品。
- 你可以使用企业邮箱、云邮箱或本地测试 SMTP 服务(例如 MailHog作为 SMTP 服务端。
当前密码重置页面入口说明:
- 当前前端统一使用 `/reset-password` 页面。
- 该页面同时包含“发送验证码”和“提交新密码”两步,不再单独使用 `/forgot-password`
## 1. 需要配置的环境变量
以下变量已被后端读取:
| 变量名 | 说明 | 示例 |
|---|---|---|
| `SPRING_MAIL_HOST` | SMTP 服务器地址 | `smtp.example.com` |
| `SPRING_MAIL_PORT` | SMTP 端口 | `465` |
| `SPRING_MAIL_USERNAME` | SMTP 用户名 | `noreply@example.com` |
| `SPRING_MAIL_PASSWORD` | SMTP 密码/授权码 | `xxxxxx` |
| `SPRING_MAIL_SMTP_AUTH` | 是否启用 SMTP AUTH | `true` |
| `SPRING_MAIL_SMTP_STARTTLS_ENABLE` | 是否启用 STARTTLS | `false` |
| `SPRING_MAIL_PROPERTIES_MAIL_SMTP_SSL_ENABLE` | 是否启用 SMTP SSL 直连 | `true` |
| `SPRING_MAIL_PROPERTIES_MAIL_SMTP_SSL_TRUST` | SSL 信任主机(用于规避部分环境下证书链校验失败) | `smtp.mail.example` |
| `SKILLHUB_AUTH_PASSWORD_RESET_CODE_EXPIRY` | 验证码有效期ISO-8601 Duration | `PT10M` |
| `SKILLHUB_AUTH_PASSWORD_RESET_FROM_ADDRESS` | 发件人邮箱 | `noreply@example.com` |
| `SKILLHUB_AUTH_PASSWORD_RESET_FROM_NAME` | 发件人名称 | `SkillHub` |
说明:
- 当前文档统一按 `465 + SSL` 配置,不再展开 `587 + STARTTLS` 方案。
- 使用 `465` 时配置:`STARTTLS=false``SSL_ENABLE=true`
- 若出现 `PKIX path building failed` / `SSLHandshakeException`,可尝试增加 `SPRING_MAIL_PROPERTIES_MAIL_SMTP_SSL_TRUST=<SMTP_HOST>`(本地联调常用)。
- 生产环境默认不建议配置 `SPRING_MAIL_PROPERTIES_MAIL_SMTP_SSL_TRUST`,仅在证书链异常时临时启用。
- `SKILLHUB_AUTH_PASSWORD_RESET_CODE_EXPIRY` 支持如 `PT5M``PT10M``PT30M`
## 1.1 配置方案速查(推荐)
### A. 通用 SMTP 邮箱(本地直连真实邮箱)
```dotenv
SPRING_MAIL_HOST=smtp.mail.example
SPRING_MAIL_PORT=465
SPRING_MAIL_USERNAME=mailer@example.com
SPRING_MAIL_PASSWORD=your-smtp-app-password
SPRING_MAIL_SMTP_AUTH=true
SPRING_MAIL_SMTP_STARTTLS_ENABLE=false
SPRING_MAIL_PROPERTIES_MAIL_SMTP_SSL_ENABLE=true
SPRING_MAIL_PROPERTIES_MAIL_SMTP_SSL_TRUST=smtp.mail.example
SKILLHUB_AUTH_PASSWORD_RESET_CODE_EXPIRY=PT10M
SKILLHUB_AUTH_PASSWORD_RESET_FROM_ADDRESS=mailer@example.com
SKILLHUB_AUTH_PASSWORD_RESET_FROM_NAME=your-from-name
```
本地 `export` 示例写法:
```bash
export SPRING_MAIL_PROPERTIES_MAIL_SMTP_SSL_TRUST=smtp.mail.example
export SPRING_MAIL_HOST=smtp.mail.example
export SPRING_MAIL_PORT=465
export SPRING_MAIL_USERNAME=mailer@example.com
export SPRING_MAIL_PASSWORD=your-smtp-app-password
export SPRING_MAIL_SMTP_AUTH=true
export SPRING_MAIL_SMTP_STARTTLS_ENABLE=false
export SPRING_MAIL_PROPERTIES_MAIL_SMTP_SSL_ENABLE=true
export SKILLHUB_AUTH_PASSWORD_RESET_CODE_EXPIRY=PT10M
export SKILLHUB_AUTH_PASSWORD_RESET_FROM_ADDRESS=mailer@example.com
export SKILLHUB_AUTH_PASSWORD_RESET_FROM_NAME=your-from-name
```
### B. MailHog本地联调推荐
```dotenv
SPRING_MAIL_HOST=127.0.0.1
SPRING_MAIL_PORT=1025
SPRING_MAIL_USERNAME=
SPRING_MAIL_PASSWORD=
SPRING_MAIL_SMTP_AUTH=false
SPRING_MAIL_SMTP_STARTTLS_ENABLE=false
SPRING_MAIL_PROPERTIES_MAIL_SMTP_SSL_ENABLE=false
SKILLHUB_AUTH_PASSWORD_RESET_FROM_ADDRESS=noreply@skillhub.local
SKILLHUB_AUTH_PASSWORD_RESET_FROM_NAME=SkillHub
```
### C. 线上部署465 端口示例)
```dotenv
SPRING_MAIL_HOST=smtp.mail.example
SPRING_MAIL_PORT=465
SPRING_MAIL_USERNAME=mailer@example.com
SPRING_MAIL_PASSWORD=your-smtp-app-password
SPRING_MAIL_SMTP_AUTH=true
SPRING_MAIL_SMTP_STARTTLS_ENABLE=false
SPRING_MAIL_PROPERTIES_MAIL_SMTP_SSL_ENABLE=true
SPRING_MAIL_PROPERTIES_MAIL_SMTP_SSL_TRUST=smtp.mail.example
SKILLHUB_AUTH_PASSWORD_RESET_CODE_EXPIRY=PT10M
SKILLHUB_AUTH_PASSWORD_RESET_FROM_ADDRESS=mailer@example.com
SKILLHUB_AUTH_PASSWORD_RESET_FROM_NAME=your-from-name
```
## 2. 单机交付Compose配置步骤
1. 复制环境模板(若尚未创建):
```bash
cp .env.release.example .env.release
```
2. 编辑 `.env.release`,填写 SMTP 变量:
```dotenv
SPRING_MAIL_HOST=smtp.mail.example
SPRING_MAIL_PORT=465
SPRING_MAIL_USERNAME=mailer@example.com
SPRING_MAIL_PASSWORD=your-smtp-app-password
SPRING_MAIL_SMTP_AUTH=true
SPRING_MAIL_SMTP_STARTTLS_ENABLE=false
SPRING_MAIL_PROPERTIES_MAIL_SMTP_SSL_ENABLE=true
SPRING_MAIL_PROPERTIES_MAIL_SMTP_SSL_TRUST=smtp.mail.example
SKILLHUB_AUTH_PASSWORD_RESET_CODE_EXPIRY=PT10M
SKILLHUB_AUTH_PASSWORD_RESET_FROM_ADDRESS=mailer@example.com
SKILLHUB_AUTH_PASSWORD_RESET_FROM_NAME=your-from-name
```
3. 重启后端容器使配置生效:
```bash
docker compose --env-file .env.release -f compose.release.yml up -d server
```
4. 查看后端日志确认启动正常:
```bash
docker compose --env-file .env.release -f compose.release.yml logs -f server
```
## 3. 本地开发配置与验证
### 3.1 一次性临时生效(推荐)
适合当前终端临时测试,重开终端后失效。
```bash
SPRING_MAIL_HOST=smtp.mail.example \
SPRING_MAIL_PORT=465 \
SPRING_MAIL_USERNAME=mailer@example.com \
SPRING_MAIL_PASSWORD=your-smtp-app-password \
SPRING_MAIL_SMTP_AUTH=true \
SPRING_MAIL_SMTP_STARTTLS_ENABLE=false \
SPRING_MAIL_PROPERTIES_MAIL_SMTP_SSL_ENABLE=true \
SPRING_MAIL_PROPERTIES_MAIL_SMTP_SSL_TRUST=smtp.mail.example \
SKILLHUB_AUTH_PASSWORD_RESET_CODE_EXPIRY=PT10M \
SKILLHUB_AUTH_PASSWORD_RESET_FROM_ADDRESS=mailer@example.com \
SKILLHUB_AUTH_PASSWORD_RESET_FROM_NAME=your-from-name \
make dev-server
```
### 3.2 长期生效shell 配置)
如果你写到了 `~/.zshrc`,请注意:
- 必须 `source ~/.zshrc` 或重开终端后变量才会生效
- 需要在“同一个终端”启动 `make dev-server`
可先确认变量是否在当前 shell 中:
```bash
env | rg '^(SPRING_MAIL_|SKILLHUB_AUTH_PASSWORD_RESET_)'
```
### 3.3 推荐联调方式MailHog
如果你只是本地验证验证码链路,建议用 MailHog 作为本地 SMTP 服务:
1. 启动 MailHog
```bash
docker run -d --name skillhub-mailhog \
-p 1025:1025 \
-p 8025:8025 \
mailhog/mailhog
```
2. 启动依赖服务Postgres/Redis
```bash
make dev
```
3. 启动后端时注入 SMTP 环境变量(示例):
```bash
SPRING_MAIL_HOST=127.0.0.1 \
SPRING_MAIL_PORT=1025 \
SPRING_MAIL_USERNAME= \
SPRING_MAIL_PASSWORD= \
SPRING_MAIL_SMTP_AUTH=false \
SPRING_MAIL_SMTP_STARTTLS_ENABLE=false \
SPRING_MAIL_PROPERTIES_MAIL_SMTP_SSL_ENABLE=false \
SKILLHUB_AUTH_PASSWORD_RESET_FROM_ADDRESS=noreply@skillhub.local \
SKILLHUB_AUTH_PASSWORD_RESET_FROM_NAME=SkillHub \
make dev-server
```
4. 打开 MailHog Web UI 查看邮件:
```text
http://localhost:8025
```
5. 在 SkillHub 页面验证流程:
- 打开 `/reset-password`
- 输入邮箱并点击“发送验证码”
- 在 MailHog 中查看验证码邮件
- 输入邮箱 + 验证码 + 新密码完成重置
6. 也可使用接口做快速验证(示例):
```bash
curl -X POST http://localhost:8080/api/v1/auth/local/password-reset/request \
-H 'Content-Type: application/json' \
-d '{"email":"your-email@example.com"}'
```
## 4. 功能验证(验证码邮件)
### 4.1 用户自助找回
`/reset-password` 页面点击“发送验证码”后,系统会尝试发送验证码邮件。
说明:
- 为防止账号枚举,自助接口总是返回通用成功提示。
- 即使邮件发送失败,接口也可能返回成功;请结合后端日志确认实际发送结果。
### 4.2 管理员触发重置
管理员在用户管理页触发“重置密码”时,系统会强制发送验证码;
若 SMTP 发送失败,会返回错误(便于运维排障)。
## 5. 常见问题排查
### 5.1 认证失败(`535 Authentication failed`
排查方向:
- 用户名/密码是否正确
- 邮箱服务是否要求“客户端授权码”而非登录密码
- 发件账号是否已开启 SMTP 服务
### 5.2 连接超时或拒绝连接
排查方向:
- 主机到 SMTP 服务端口 `465` 是否可达
- 安全组/防火墙是否放行出站连接
- SMTP 服务地址是否填写正确
### 5.3 本地明明配置了变量但不生效
排查方向:
- 是否只是编辑了 `~/.zshrc` 但没有 `source ~/.zshrc`
- 启动后端的终端是否与配置变量的终端是同一个
- `8080` 是否被旧进程占用,导致新进程没启动成功
可执行以下命令快速检查:
```bash
# 查看 8080 是否被旧进程占用
lsof -nP -iTCP:8080 -sTCP:LISTEN
# 查看当前 shell 是否有 SMTP 环境变量
env | rg '^(SPRING_MAIL_|SKILLHUB_AUTH_PASSWORD_RESET_)'
```
### 5.4 发件人被拒绝
排查方向:
- `SKILLHUB_AUTH_PASSWORD_RESET_FROM_ADDRESS` 是否与 SMTP 账号一致或已验证
- 邮箱服务是否限制别名发件
### 5.5 健康检查是否校验 SMTP
默认配置下,邮件健康检查关闭,不会因为 SMTP 不可达导致 `health` 失败。
若需要将 SMTP 连通性纳入健康检查,可设置:
```dotenv
MANAGEMENT_HEALTH_MAIL_ENABLED=true
```
### 5.6 SMTP 报 `PKIX path building failed`(证书链校验失败)
典型日志:
- `SSLHandshakeException`
- `unable to find valid certification path to requested target`
处理建议(本地联调):
- 增加:
```dotenv
SPRING_MAIL_PROPERTIES_MAIL_SMTP_SSL_TRUST=smtp.mail.example
```
- 然后重启后端,再触发一次“发送验证码”。
补充:
- 该配置用于指定信任主机,适合本地排障与联调。
- 生产环境默认不建议长期启用该配置,更推荐使用规范 CA 证书链或将企业 CA 导入 Java truststore。
## 6. 安全建议
- 不要把 SMTP 密码提交到仓库;仅写入受控的 `.env.release` 或密钥管理系统。
- 使用专用发信账号,避免使用个人邮箱主密码。
- 生产环境建议定期轮换 SMTP 授权码。

View file

@ -110,8 +110,11 @@ npx clawhub list --help
### 5. Publish Skills
```bash
# Publish skill (requires appropriate permissions)
# Publish to the global namespace (requires appropriate permissions)
npx clawhub publish ./my-skill --slug my-skill --name "My Skill" --version 1.0.0
# Publish to a team namespace such as my-space
npx clawhub publish ./my-skill --slug my-space--my-skill --name "My Skill" --version 1.0.0
npx clawhub sync --all # Upload all skills in current folder
# Help
@ -119,6 +122,10 @@ npx clawhub publish --help
npx clawhub sync --help
```
Notes:
- `my-space--my-skill` is the canonical compatibility slug. SkillHub parses it as namespace `my-space` plus skill slug `my-skill`
- To avoid mismatches between CLI display text and the final persisted coordinate, keep the `name` in `SKILL.md` aligned with the canonical slug suffix
## API Endpoints
SkillHub compatibility layer provides the following endpoints:

View file

@ -110,8 +110,11 @@ npx clawhub list --help
### 5. 发布技能
```bash
# 发布技能(需要相应权限)
# 发布到 global 空间(需要相应权限)
npx clawhub publish ./my-skill --slug my-skill --name "My Skill" --version 1.0.0
# 发布到如 my-space 这样的团队空间
npx clawhub publish ./my-skill --slug my-space--my-skill --name "My Skill" --version 1.0.0
npx clawhub sync --all # 上传当前文件夹中所有的 skill
# 使用帮助
@ -119,6 +122,10 @@ npx clawhub publish --help
npx clawhub sync --help
```
说明:
- `my-space--my-skill` 是兼容层 canonical slugSkillHub 会将其解析为 namespace `my-space` 和 skill slug `my-skill`
- 为避免 CLI 展示与服务端最终坐标不一致,建议让 `SKILL.md` 中的 `name` 与 canonical slug 后半段保持一致
## API 端点说明
SkillHub 兼容层提供以下端点:

View file

@ -0,0 +1,460 @@
# OSS-01 Core 契约审计与冻结
## 1. 审计结论
SkillHub 开源项目已具备 AstronClaw 主链路所需的绝大部分 Core 能力。现有接口覆盖了 skill 唯一标识查询、版本元数据查询、创建(发布)和删除。**无需在开源 Core 中新增 AstronClaw 专属接口**;对 AstronClaw 而言,查询类和主链路类能力都应统一由 SaaS 层 `AstronClaw Adapter` 封装后对外提供,而不是直接绑定开源 Core 的接口形态。
---
## 2. Core 接口清单
以下接口构成 Core 基线能力,供 SaaS 层统一封装后对 AstronClaw 提供;这些接口本身不应被视为 AstronClaw 的长期直接契约。
### 2.1 skill 唯一标识与详情查询
| 接口 | 路径 | 说明 |
|------|------|------|
| skill 详情 | `GET /api/v1/skills/{namespace}/{slug}` | 返回 `SkillDetailResponse`,包含完整 identity 和状态 |
| 版本解析 | `GET /api/v1/skills/{namespace}/{slug}/resolve?version=&tag=&hash=` | 返回 `ResolveVersionResponse`,解析人类可读版本选择器到精确版本 |
### 2.2 指定版本安装元数据查询
| 接口 | 路径 | 说明 |
|------|------|------|
| 版本详情 | `GET /api/v1/skills/{namespace}/{slug}/versions/{version}` | 返回 `SkillVersionDetailResponse`,含 metadata 和 manifest |
| 版本文件列表 | `GET /api/v1/skills/{namespace}/{slug}/versions/{version}/files` | 返回 `List<SkillFileResponse>` |
| 版本下载 | `GET /api/v1/skills/{namespace}/{slug}/versions/{version}/download` | 下载指定版本 bundle |
| 版本列表 | `GET /api/v1/skills/{namespace}/{slug}/versions?page=&size=` | 分页返回版本列表 |
### 2.3 创建(发布)个人 skill
| 接口 | 路径 | 说明 |
|------|------|------|
| 发布 skill | `POST /api/v1/skills/{namespace}/publish` | 上传包并发布,返回 `PublishResponse` |
### 2.4 删除个人 skill
| 接口 | 路径 | 说明 |
|------|------|------|
| 硬删除by ID | `DELETE /api/v1/skills/id/{skillId}` | 需 SUPER_ADMIN 权限 |
| 硬删除by 坐标) | `DELETE /api/v1/skills/{namespace}/{slug}` | 需 SUPER_ADMIN 权限 |
| 归档 | `POST /api/v1/skills/{namespace}/{slug}/archive` | owner 或 namespace admin 可操作 |
| 取消归档 | `POST /api/v1/skills/{namespace}/{slug}/unarchive` | 恢复为 ACTIVE |
### 2.5 版本生命周期
| 接口 | 路径 | 说明 |
|------|------|------|
| 删除版本 | `DELETE /api/v1/skills/{namespace}/{slug}/versions/{version}` | 仅 DRAFT/REJECTED/SCAN_FAILED 可删 |
| 撤回审核 | `POST /api/v1/skills/{namespace}/{slug}/versions/{version}/withdraw-review` | PENDING_REVIEW → DRAFT |
| 重新发布 | `POST /api/v1/skills/{namespace}/{slug}/versions/{version}/rerelease` | 重新发布版本 |
### 2.6 ClawHub 兼容接口(已有)
| 接口 | 路径 | 说明 |
|------|------|------|
| 解析 skill | `GET /api/v1/resolve?slug=&version=` | ClawHub 协议兼容 |
| 解析 skill路径 | `GET /api/v1/resolve/{canonicalSlug}?version=` | ClawHub 协议兼容 |
| 下载 | `GET /api/v1/download/{canonicalSlug}?version=` | 302 重定向到下载地址 |
| 删除 skill | `DELETE /api/v1/skills/{canonicalSlug}` | owner 可操作 |
| 取消删除 | `POST /api/v1/skills/{canonicalSlug}/undelete` | owner 可操作 |
| 发布 skill | `POST /api/v1/skills` | ClawHub 协议兼容 |
| 发布到 namespace | `POST /api/v1/publish` | ClawHub 协议兼容 |
---
## 3. 字段语义冻结表
### 3.1 Skill Identity 字段
| 字段 | 类型 | 含义 | 稳定性 | 说明 |
|------|------|------|--------|------|
| `skill.id` | Long | skill 全局唯一主键 | 不可变 | 自增,创建后永不改变,可作为外部映射主键 |
| `namespace` (slug) | String(64) | skill 所属命名空间标识 | 不可变 | 全局唯一,创建后不可改名 |
| `skill.slug` | String(100) | skill 在 namespace 内的唯一标识 | 不可变 | 创建后不可改名,`namespace + slug` 构成业务坐标 |
| `skill.displayName` | String(200) | skill 展示名称 | 可变 | 仅用于展示,不可作为映射依据 |
| `skill.ownerId` | String | skill 创建者 ID | 不可变 | 创建时绑定,不可转移 |
| `skill.summary` | String(TEXT) | skill 简介 | 可变 | 展示用 |
| `skill.visibility` | Enum | 可见性 | 可变 | `PUBLIC` / `NAMESPACE_ONLY` / `PRIVATE` |
| `skill.status` | Enum | skill 状态 | 可变 | `ACTIVE` / `HIDDEN` / `ARCHIVED` |
| `skill.hidden` | boolean | 是否被管理员隐藏 | 可变 | 与 status 独立的隐藏标记 |
| `skill.latestVersionId` | Long | 最新版本指针 | 可变 | 指向当前最新已发布版本yank/删除后自动回退 |
| `skill.downloadCount` | Long | 下载次数 | 可变 | 累计值 |
| `skill.starCount` | Integer | 收藏数 | 可变 | 累计值 |
### 3.2 SkillVersion 字段
| 字段 | 类型 | 含义 | 稳定性 | 说明 |
|------|------|------|--------|------|
| `version.id` | Long | 版本全局唯一主键 | 不可变 | 自增 |
| `version.skillId` | Long | 所属 skill ID | 不可变 | 外键 |
| `version.version` | String(64) | 版本号 | 不可变 | 如 `1.0.0`,创建后不可改 |
| `version.status` | Enum | 版本状态 | 可变 | 见状态语义表 |
| `version.bundleReady` | boolean | bundle 是否可用 | 可变 | `true` 表示 bundle 已构建完成,可下载安装 |
| `version.downloadReady` | boolean | 是否允许下载 | 可变 | yank 后设为 `false` |
| `version.publishedAt` | Instant | 发布时间 | 一次写入 | 首次发布时设置 |
| `version.parsedMetadataJson` | JSONB | 解析后的元数据 | 一次写入 | 包含 `package_name` 等运行时信息 |
| `version.manifestJson` | JSONB | manifest 原始内容 | 一次写入 | skill 包的 manifest |
| `version.changelog` | String(TEXT) | 变更日志 | 可变 | 展示用 |
| `version.fileCount` | Integer | 文件数量 | 一次写入 | 发布时确定 |
| `version.totalSize` | Long | 总大小(字节) | 一次写入 | 发布时确定 |
| `version.yankedAt` | Instant | yank 时间 | 一次写入 | yank 时设置 |
| `version.yankReason` | String(TEXT) | yank 原因 | 一次写入 | yank 时设置 |
### 3.3 关键字段含义冻结
| 字段 | 冻结定义 |
|------|----------|
| `skill_id` | `skill.id`Long 类型自增主键全局唯一创建后不可变。AstronClaw 应以此作为 `external_skill_mapping` 的外部主键 |
| `namespace` | `namespace.slug`String(64),全局唯一,不可改名。与 `slug` 组合构成业务坐标 |
| `slug` | `skill.slug`String(100)namespace 内唯一,不可改名。`namespace/slug` 是人类可读的稳定坐标 |
| `version` | `skill_version.version`String(64),同一 skill 内唯一,不可改。如 `1.0.0` |
| `bundle_url` | 通过 `GET /{namespace}/{slug}/versions/{version}/download` 获取,或通过 `resolve` 接口的 `downloadUrl` 字段获取。不是数据库字段,而是动态生成的下载地址 |
| `bundle_ready` | `skill_version.bundleReady`boolean。`true` 表示 bundle 已构建完成可安装。AstronClaw 安装前必须校验此字段 |
| `package_name` | 存储在 `skill_version.parsedMetadataJson` 中,从 skill 包的 manifest 解析而来。同一 skill 跨版本应保持稳定。AstronClaw 用于运行时安装/卸载标识 |
### 3.4 Namespace 字段
| 字段 | 类型 | 含义 | 稳定性 |
|------|------|------|--------|
| `namespace.id` | Long | 命名空间主键 | 不可变 |
| `namespace.slug` | String(64) | 命名空间标识 | 不可变,全局唯一 |
| `namespace.displayName` | String(128) | 展示名称 | 可变 |
| `namespace.type` | Enum | 类型 | 不可变,`GLOBAL` / `TEAM` |
| `namespace.status` | Enum | 状态 | 可变,`ACTIVE` / `FROZEN` / `ARCHIVED` |
---
## 4. 状态语义冻结表
### 4.1 Skill 状态(`SkillStatus`
| 状态 | 市场可见 | 可新装 | 已装是否保留 | 可被 owner 操作 | 说明 |
|------|----------|--------|------------|----------------|------|
| `ACTIVE` | 是(受 visibility 控制) | 是(需有 PUBLISHED 版本) | 是 | 是 | 正常状态 |
| `HIDDEN` | 否 | 否 | 是 | 受限 | 管理员隐藏,独立于 status 的 `hidden` 标记 |
| `ARCHIVED` | 否 | 否 | 是 | 可取消归档 | owner 或 namespace admin 归档 |
### 4.2 版本状态(`SkillVersionStatus`
| 状态 | 是否允许安装 | 是否允许下载 | 市场可见 | 可转换到 | 说明 |
|------|------------|------------|---------|---------|------|
| `DRAFT` | 否 | 否 | 否 | SCANNING, 可删除 | 初始状态,编辑中 |
| `SCANNING` | 否 | 否 | 否 | SCAN_FAILED, PENDING_REVIEW, PUBLISHED | 安全扫描中 |
| `SCAN_FAILED` | 否 | 否 | 否 | 可删除 | 安全扫描失败 |
| `PENDING_REVIEW` | 否 | 否 | 否 | PUBLISHED, REJECTED, → DRAFT(撤回) | 等待审核 |
| `PUBLISHED` | 是 | 是 | 是 | YANKED | 已发布,可安装 |
| `REJECTED` | 否 | 否 | 否 | 可删除 | 审核拒绝 |
| `YANKED` | 否 | 否 | 否(或弱可见) | 不可逆 | 已撤回,已装不受影响 |
### 4.3 可见性(`SkillVisibility`
| 可见性 | 市场列表可见 | 谁可查看 | 谁可安装 |
|--------|------------|---------|---------|
| `PUBLIC` | 是 | 所有人 | 所有人(需 PUBLISHED + bundleReady |
| `NAMESPACE_ONLY` | 否 | namespace 成员 | namespace 成员 |
| `PRIVATE` | 否 | 仅 owner | 仅 owner |
### 4.4 删除语义
| 操作 | 类型 | 可逆 | 数据影响 | 已装实例影响 |
|------|------|------|---------|------------|
| 硬删除 skill | 永久删除 | 否 | 删除所有记录、文件、存储对象slug 可复用 | 不影响AstronClaw 已装快照独立 |
| 归档 skill | 状态变更 | 是 | 无数据删除status → ARCHIVED | 不影响 |
| 隐藏 skill | 标记变更 | 是 | 无数据删除hidden → true | 不影响 |
| 删除版本 | 永久删除 | 否 | 仅删除 DRAFT/REJECTED/SCAN_FAILED 版本 | 不影响(这些版本未被安装) |
| Yank 版本 | 状态变更 | 否 | status → YANKEDdownloadReady → false | 不影响已装实例 |
### 4.5 AstronClaw 安装判断规则
AstronClaw 判断一个 skill 版本是否可安装,需同时满足:
```
skill.status == ACTIVE
AND skill.hidden == false
AND skill.visibility 允许当前用户访问
AND version.status == PUBLISHED
AND version.bundleReady == true
```
已安装实例不受后续状态变更影响。即使 skill 被删除/归档/隐藏,或版本被 yankAstronClaw 本地安装快照仍可正常使用和卸载。
## 5. 错误语义表
### 5.1 统一响应结构
```json
{
"code": 0,
"msg": "操作成功",
"data": { ... },
"timestamp": "2026-04-10T08:00:00Z",
"requestId": "req-xxx"
}
```
- `code = 0` 表示成功
- `code > 0` 表示错误,值为 HTTP 状态码
### 5.2 错误码映射
| HTTP 状态码 | 场景 | 异常类型 | 说明 |
|------------|------|---------|------|
| 400 | 参数非法 | `BadRequestException` / `DomainBadRequestException` | 请求参数校验失败 |
| 401 | 未认证 | `UnauthorizedException` / `AuthFlowException` | 未登录或 token 过期 |
| 403 | 无权限 | `ForbiddenException` / `DomainForbiddenException` | 无操作权限 |
| 404 | 未找到 | `DomainNotFoundException` | skill/version/namespace 不存在 |
| 408 | 请求超时 | `AsyncRequestTimeoutException` | 异步请求超时 |
| 503 | 存储不可用 | `StorageAccessException` | 对象存储访问失败 |
| 500 | 服务异常 | `Exception` | 未预期的内部错误 |
### 5.3 Core 主链路关键错误场景
| 场景 | HTTP 状态码 | msg 示例 | AstronClaw 处理建议 |
|------|-----------|---------|-------------------|
| skill 不存在 | 404 | `error.skill.notFound` | 映射失败,提示用户 |
| 版本不存在 | 404 | `error.skill.notFound` | 安装/升级失败,提示用户 |
| 版本不可安装(非 PUBLISHED | 400 | `error.badRequest` | 拒绝安装,提示版本状态 |
| bundle 未就绪 | 400 | `error.badRequest` | 拒绝安装,提示稍后重试 |
| 无权访问PRIVATE skill | 403 | `error.forbidden` | 提示无权限 |
| namespace 不存在 | 404 | `error.namespace.notFound` | 映射失败 |
| 存储服务不可用 | 503 | `error.storage.unavailable` | 降级处理,已装 skill 不受影响 |
| 删除不允许(非 owner | 403 | `error.forbidden` | 提示无权限 |
---
## 6. Core vs SaaS Adapter 能力分界
### 6.1 Core 已满足的能力
说明:
下表表示“开源 Core 已具备、可供 SaaS 封装”的能力,并不表示 AstronClaw 应直接调用这些开源接口。
| PRD 需求 | Core 接口 | 满足程度 | 备注 |
|---------|----------|---------|------|
| skill 唯一标识查询 | `GET /{namespace}/{slug}` | 完全满足 | 返回 `id``namespace``slug` |
| 指定版本安装元数据 | `GET /{namespace}/{slug}/versions/{version}` | 基本满足 | 返回 status、metadata`package_name``parsedMetadataJson` 中 |
| 版本解析 | `GET /{namespace}/{slug}/resolve` | 完全满足 | 支持 version/tag/hash 解析 |
| bundle 下载 | `GET /{namespace}/{slug}/versions/{version}/download` | 完全满足 | 直接下载 |
| 创建(发布)个人 skill | `POST /{namespace}/publish` | 完全满足 | 返回 skillId、namespace、slug、version、status |
| 删除个人 skill | `DELETE /{namespace}/{slug}` (ClawHub 兼容) | 完全满足 | owner 可操作 |
| 归档 skill | `POST /{namespace}/{slug}/archive` | 完全满足 | 可逆操作 |
| 版本状态查询 | `GET /{namespace}/{slug}` 中的 headlineVersion/publishedVersion | 完全满足 | 包含版本状态 |
| labels 数据 | `GET /{namespace}/{slug}` 中的 labels 字段 | 完全满足 | 返回 `List<SkillLabelDto>` |
### 6.2 需要 SaaS Adapter 新增的能力
| PRD 需求 | 原因 | Adapter 建议 |
|---------|------|-------------|
| 市场列表查询(搜索/过滤/排序) | Core 不提供面向页面的聚合列表 | `GET /api/v1/astronclaw/adapter/skills/market` |
| 市场详情AstronClaw DTO | Core 返回的 DTO 包含 Core 内部字段,需适配 | `GET /api/v1/astronclaw/adapter/skills/{id}` |
| owner 维度"我创建的"查询 | Core 的 `/me/skills` 返回 Core DTO需适配 | `GET /api/v1/astronclaw/adapter/skills/mine` |
| `is_installed` 补全 | 安装关系在 AstronClaw 侧 | AstronClaw 本地补全,不在 Adapter |
| `package_name` 顶层字段 | 当前在 `parsedMetadataJson` 内,需提取 | Adapter 解析 JSON 后平铺返回 |
| `bundle_url` 直接返回 | 当前需通过 download 接口获取 | Adapter 可直接返回预签名 URL |
| 统一 `can_install` 判断 | 需组合 status + visibility + bundleReady | Adapter 计算后返回布尔值 |
| 统一 `can_delete` 判断 | 需组合 owner + status | Adapter 计算后返回布尔值 |
### 6.3 分界原则
```
Core 负责skill 生命周期真相identity、version、status、artifact
Adapter 负责:面向 AstronClaw 的 DTO 适配(字段平铺、状态聚合、权限预判断)
```
补充原则:
1. 即使开源 `Core` 已经具备某项主链路能力,`AstronClaw` 仍应统一通过 SaaS Adapter 消费。
2. 该原则同时适用于唯一标识查询、版本元数据、创建个人 skill、删除个人 skill。
3. 开源文档中的接口清单用于说明 `Core` 能力边界,不应被解读为 AstronClaw 的直接对接建议。
---
## 7. 成功 / 失败 / 边界样例
### 7.1 查询 skill identity — 成功
```
GET /api/v1/skills/my-namespace/my-skill
```
```json
{
"code": 0,
"data": {
"id": 42,
"slug": "my-skill",
"displayName": "My Skill",
"ownerId": "user-123",
"status": "ACTIVE",
"visibility": "PUBLIC",
"namespace": "my-namespace",
"labels": [{"slug": "nlp", "type": "CATEGORY", "displayName": "NLP"}],
"headlineVersion": {"id": 100, "version": "1.2.0", "status": "PUBLISHED"},
"publishedVersion": {"id": 100, "version": "1.2.0", "status": "PUBLISHED"}
}
}
```
AstronClaw 映射关键字段:`id=42``namespace=my-namespace``slug=my-skill`
### 7.2 查询 skill identity — 不存在
```
GET /api/v1/skills/my-namespace/nonexistent
```
```json
{
"code": 404,
"msg": "Skill not found",
"data": null
}
```
### 7.3 查询指定版本元数据 — 成功
```
GET /api/v1/skills/my-namespace/my-skill/versions/1.2.0
```
```json
{
"code": 0,
"data": {
"id": 100,
"version": "1.2.0",
"status": "PUBLISHED",
"changelog": "Bug fixes",
"fileCount": 3,
"totalSize": 102400,
"publishedAt": "2026-04-01T10:00:00Z",
"parsedMetadataJson": "{\"name\":\"my-skill\",\"package_name\":\"my_namespace__my_skill\",\"version\":\"1.2.0\"}",
"manifestJson": "{...}"
}
}
```
`package_name``parsedMetadataJson` 中提取。
### 7.4 查询已 YANKED 版本
```
GET /api/v1/skills/my-namespace/my-skill/versions/1.0.0
```
```json
{
"code": 0,
"data": {
"id": 98,
"version": "1.0.0",
"status": "YANKED",
"publishedAt": "2026-03-01T10:00:00Z"
}
}
```
AstronClaw 判断 `status != PUBLISHED`,拒绝新安装。已装实例不受影响。
### 7.5 发布(创建)个人 skill — 成功
```
POST /api/v1/skills/my-namespace/publish
Content-Type: multipart/form-data
file: <skill-package.tar.gz>
visibility: PRIVATE
```
```json
{
"code": 0,
"data": {
"skillId": 43,
"namespace": "my-namespace",
"slug": "new-skill",
"version": "0.1.0",
"status": "DRAFT",
"fileCount": 2,
"totalSize": 51200
}
}
```
### 7.6 删除个人 skill — 成功
```
DELETE /api/v1/skills/my-namespace/my-skill
```
```json
{
"code": 0,
"data": {
"ok": true
}
}
```
### 7.7 删除个人 skill — 无权限
```
DELETE /api/v1/skills/other-namespace/other-skill
```
```json
{
"code": 403,
"msg": "Forbidden",
"data": null
}
```
### 7.8 边界skill 已归档后查询
```
GET /api/v1/skills/my-namespace/archived-skill
```
```json
{
"code": 0,
"data": {
"id": 44,
"slug": "archived-skill",
"status": "ARCHIVED",
"visibility": "PUBLIC"
}
}
```
skill 仍可查询,但 AstronClaw 应根据 `status=ARCHIVED` 判断不可新装。
---
## 8. 遗留问题与建议
### 8.1 `package_name` 提取
当前 `package_name` 嵌套在 `parsedMetadataJson` JSONB 字段中,不是顶层字段。
建议SaaS Adapter 在返回 AstronClaw DTO 时,解析 JSON 并将 `package_name` 提取为顶层字段。Core 不需要改动。
### 8.2 `bundle_url` 获取方式
当前没有直接返回 `bundle_url` 的字段,需通过 download 接口获取。`ResolveVersionResponse` 中有 `downloadUrl` 字段。
建议SaaS Adapter 可通过 `resolve` 接口获取 `downloadUrl`,或直接生成预签名 URL 返回给 AstronClaw。
### 8.3 删除接口权限
当前 `DELETE /api/v1/skills/{namespace}/{slug}`portal 路径)需要 SUPER_ADMIN 权限。ClawHub 兼容接口 `DELETE /api/v1/skills/{canonicalSlug}` 允许 owner 操作。
建议SaaS Adapter 应统一封装 owner 可操作的删除接口,对 AstronClaw 暴露稳定契约AstronClaw 不直接依赖开源删除接口路径。
### 8.4 `hidden``status` 的关系
当前 `hidden` 是独立于 `status` 的布尔标记(管理员操作),而 `HIDDEN``SkillStatus` 枚举值之一但实际代码中 skill 的 status 枚举包含 `ACTIVE``HIDDEN``ARCHIVED`
建议SaaS Adapter 统一为 AstronClaw 提供一个 `is_visible` 聚合字段,屏蔽内部 hidden 标记与 status 的复杂关系。

View file

@ -0,0 +1,663 @@
# OSS-02 Core 语义规则收口
## 1. 文档目标
本文档固化 SkillHub Core 的运行时语义规则,确保开源版与 SaaS 版对删除、YANKED、同名冲突、package_name 等规则口径一致,避免 AstronClaw 接入后出现状态漂移。本文定义的是可由 SaaS 统一封装并对 AstronClaw 提供的 `Core` 规则基线,不表示 AstronClaw 直接对接这些开源接口。
---
## 2. 变更概要
### 2.1 新增功能
| 功能 | 说明 |
|------|------|
| UPLOADED 状态 | 新增版本状态,表示"已上传,未提交审核" |
| PRIVATE skill 自动发布 | PRIVATE skill 发布后进入 UPLOADED 状态,不自动进入审核 |
| 提交审核接口 | 新增 `POST /{namespace}/{slug}/submit-review`,允许 UPLOADED 状态的版本提交审核 |
| 撤回审核后进入 UPLOADED | 撤回审核后版本状态变为 UPLOADED而不是 DRAFT |
### 2.2 状态机变更
**变更前**
```
DRAFT → SCANNING → PENDING_REVIEW → PUBLISHED
↓ ↓
REJECTED YANKED
```
**变更后**
```
DRAFT → SCANNING → UPLOADED → PENDING_REVIEW → PUBLISHED
↓ ↓ ↓ ↓
SCAN_FAILED (可删除) REJECTED YANKED
↓ ↓
(可删除) (可删除)
```
### 2.3 权限模型变更
**核心原则**:权限只和 status 相关visibility 只影响状态流转。
---
## 3. 版本状态定义
### 3.1 状态枚举
```java
public enum SkillVersionStatus {
DRAFT, // 草稿,编辑中
SCANNING, // 安全扫描中
SCAN_FAILED, // 扫描失败
UPLOADED, // 已上传,未提交审核(新增)
PENDING_REVIEW, // 等待审核
PUBLISHED, // 已发布
REJECTED, // 审核拒绝
YANKED // 已撤回
}
```
### 3.2 状态语义
| 状态 | 含义 | 文件状态 | 可下载 | 可编辑 | 有检测报告 |
|------|------|---------|-------|-------|----------|
| DRAFT | 草稿,编辑中 | 可能不完整 | 否 | 是 | 否 |
| SCANNING | 安全扫描中 | 完整 | 否 | 否 | 否 |
| SCAN_FAILED | 扫描失败 | 完整 | 否 | 是 | 是(失败) |
| UPLOADED | 已上传,扫描通过 | 完整 | owner | 否 | 是 |
| PENDING_REVIEW | 审核中 | 完整 | owner | 否 | 是 |
| PUBLISHED | 已发布 | 完整 | 看 visibility | 否 | 是 |
| REJECTED | 审核拒绝 | 完整 | 否 | 是 | 是 |
| YANKED | 已撤回 | 完整 | 否 | 否 | 是 |
---
## 4. 发布流程设计
### 4.1 发布路径
| visibility | 发布后初始状态 | 是否创建审核任务 |
|------------|--------------|----------------|
| PRIVATE | UPLOADED | 否 |
| NAMESPACE_ONLY | PENDING_REVIEW | 是 |
| PUBLIC | PENDING_REVIEW | 是 |
### 4.2 PRIVATE skill 完整生命周期
```
用户发布 PRIVATE skill
状态SCANNING安全扫描中
扫描通过
状态UPLOADED
visibilityPRIVATE
owner 可下载/安装/测试
市场不可见
管理员可见(用于审计)
已有检测报告
owner 测试满意确认发布confirm-publish
状态PUBLISHED
visibilityPRIVATE正式私有版本
owner 可下载/安装
市场不可见
用户想公开,提交审核
状态PENDING_REVIEW
requestedVisibilityPUBLIC
owner 仍可下载/测试
审核通过
状态PUBLISHED
visibilityPUBLIC不再是 PRIVATE
市场可见,所有人可下载
```
### 4.3 PUBLIC/NAMESPACE_ONLY skill 生命周期
```
用户发布 PUBLIC/NAMESPACE_ONLY skill
状态PENDING_REVIEW
owner 可下载/测试
审核通过
状态PUBLISHED
visibilityPUBLIC 或 NAMESPACE_ONLY
市场可见(受 visibility 控制)
```
---
## 5. 权限矩阵
### 5.1 status 决定下载权限
| status | 市场可见 | 可下载 |
|--------|---------|-------|
| DRAFT | 否 | 否 |
| SCANNING | 否 | 否 |
| SCAN_FAILED | 否 | 否 |
| UPLOADED | 否 | owner |
| PENDING_REVIEW | 否 | owner |
| PUBLISHED | 看 visibility | 看 visibility |
| REJECTED | 否 | 否 |
| YANKED | 否 | 否 |
### 5.2 PUBLISHED 状态下visibility 决定可见性
| visibility | 市场可见 | 可下载 |
|------------|---------|-------|
| PUBLIC | 是 | 所有人 |
| NAMESPACE_ONLY | 命名空间内 | 命名空间成员 |
| PRIVATE | 否 | owner |
### 5.3 AstronClaw 安装判断规则
```
可安装 =
skill.status == ACTIVE
AND skill.hidden == false
AND 存在至少一个可下载版本
AND 该版本 bundleReady == true
可下载版本判断:
- UPLOADED/PENDING_REVIEW仅 owner
- PUBLISHED按 visibility 规则
```
---
## 6. 状态流转详细设计
### 6.1 状态转换表
| 当前状态 | 操作 | 目标状态 | 说明 |
|---------|------|---------|------|
| DRAFT | 上传包 | SCANNING | 开始安全扫描 |
| SCANNING | 扫描通过 | UPLOADED 或 PENDING_REVIEW | 看 visibility |
| SCANNING | 扫描失败 | SCAN_FAILED | - |
| SCAN_FAILED | 重新上传 | SCANNING | - |
| UPLOADED | 提交审核 | PENDING_REVIEW | 新增操作 |
| UPLOADED | 确认发布 | PUBLISHED | PRIVATE skill 正式发布,不触发新扫描 |
| UPLOADED | 重新上传 | SCANNING | 允许重新上传 |
| UPLOADED | 删除 | (删除) | 允许删除,未正式发布 |
| PENDING_REVIEW | 审核通过 | PUBLISHED | - |
| PENDING_REVIEW | 审核拒绝 | REJECTED | - |
| PENDING_REVIEW | 撤回审核 | UPLOADED | 变更:原为 DRAFT |
| PUBLISHED | Yank | YANKED | - |
| REJECTED | 重新上传 | SCANNING | - |
### 6.2 状态机图
```
┌─────────────────────────────────────────┐
│ 上传包 │
└─────────────────────────────────────────┘
┌───────────────┐
│ SCANNING │
└───────────────┘
/ \
扫描通过 / \ 扫描失败
/ \
┌────────────────────────┐ ┌───────────────┐
│ visibility=PRIVATE │ │ SCAN_FAILED │
│ → UPLOADED │ └───────────────┘
│ visibility=PUBLIC/ │ │
│ NAMESPACE_ONLY │ │ 重新上传
│ → PENDING_REVIEW │ ↓
└────────────────────────┘ ┌───────────────┐
│ │ SCANNING │
↓ └───────────────┘
┌────────────────────────┐
│ UPLOADED │◄────────────────────────┐
│ (PRIVATE skill 专属) │ │
│ 已有检测报告 │ │
└────────────────────────┘ │
/ \ │
确认发布 / \ 提交审核 │
(不触发新扫描) / \ │
/ \ │
↓ ↓ │
┌───────────────────┐ ┌───────────────────┐ │
│ PUBLISHED │ │ PENDING_REVIEW │ │
│ visibility=PRIVATE│ └───────────────────┘ │
└───────────────────┘ │ │
│ │ │
│ 提交审核 │ 审核通过 │
↓ ↓ │
┌───────────────────┐ ┌───────────────────┐ │
│ PENDING_REVIEW │ │ PUBLISHED │ │
└───────────────────┘ │ visibility=PUBLIC │ │
│ │ 或 NAMESPACE_ONLY │ │
│ └───────────────────┘ │
│ 撤回审核 │ │
└──────────────────────┘ │
(进入 UPLOADED) │
┌───────────────────┐ │
│ REJECTED │────────────────────────────────────────┘
└───────────────────┘ 重新上传
│ 删除
(删除)
```
---
## 7. 新增接口设计
说明:
以下接口属于开源 `Core` 为 SaaS 提供的基础状态机能力。对 `AstronClaw` 而言,后续仍应统一通过 `SkillHub SaaS``AstronClaw Adapter` 消费这些能力,而不是直接绑定这些开源接口路径。
### 7.1 提交审核接口
**接口**`POST /api/v1/skills/{namespace}/{slug}/submit-review`
**请求参数**
```json
{
"version": "1.0.0",
"targetVisibility": "PUBLIC"
}
```
**前置条件**
- 版本状态为 UPLOADED
- 操作者为 skill owner 或 namespace ADMIN/OWNER
**执行效果**
- 版本状态 → PENDING_REVIEW
- `requestedVisibility` 设为目标可见性
- 创建审核任务
**响应**
```json
{
"code": 0,
"data": {
"versionId": 100,
"status": "PENDING_REVIEW",
"requestedVisibility": "PUBLIC"
}
}
```
### 7.2 确认发布接口PRIVATE skill
**接口**`POST /api/v1/skills/{namespace}/{slug}/confirm-publish`
**请求参数**
```json
{
"version": "1.0.0"
}
```
**前置条件**
- 版本状态为 UPLOADED
- skill.visibility = PRIVATE
- 操作者为 skill owner
**执行效果**
- 版本状态 → PUBLISHED
- visibility 保持 PRIVATE
- **不触发新的扫描**,复用 UPLOADED 时的扫描结果
- 未来可扩展:加入"发布扫描"功能
**响应**
```json
{
"code": 0,
"data": {
"skillId": 42,
"versionId": 100,
"status": "PUBLISHED",
"visibility": "PRIVATE"
}
}
```
---
## 8. 删除 / 隐藏 / 归档 / YANKED 语义规则
### 8.1 操作语义总表
| 操作 | 触发方式 | 可逆 | 市场可见 | 可新装 | 已装保留 | 可卸载 | slug 可复用 |
|------|---------|------|---------|-------|---------|-------|-----------|
| **硬删除 skill** | owner 或 SUPER_ADMIN | 否 | 否 | 否 | 是 | 是 | 是 |
| **归档 skill** | owner / namespace admin | 是 | 否 | 否 | 是 | 是 | 否 |
| **隐藏 skill** | 管理员 | 是 | 否 | 否 | 是 | 是 | 否 |
| **Yank 版本** | owner / namespace admin | 否 | 否 | 否 | 是 | 是 | N/A |
### 8.2 Yank 版本
**定义**YANK 是"撤回已发布版本"的操作,用于将一个已发布的版本从可用状态移除。
**触发条件**
- owner 或 namespace ADMIN/OWNER 对 PUBLISHED 状态的版本执行 yank
**执行效果**
- `version.status``YANKED`(不可逆,无 un-yank 操作)
- `version.downloadReady``false`
- 记录 `yankedAt``yankedBy``yankReason`
- 如果该版本是 `skill.latestVersionId` 指向的版本:
- 自动回退到上一个 PUBLISHED 版本
- 如果没有其他 PUBLISHED 版本,`latestVersionId``null`
**对 AstronClaw 的影响**
- 已安装实例不受影响
- 无法新装该版本
- 升级场景:目标版本被 yank → 升级失败
对接原则:
- 上述语义应由 SaaS Adapter 原样继承并稳定对外提供
- AstronClaw 通过 Adapter 感知这些状态,不直接绑定开源返回形态
**补救方式**
- 不能 un-yank
- 只能发布新版本rerelease 或重新上传)
---
## 9. 同名冲突规则
### 9.1 唯一性约束
数据库约束:`UNIQUE(namespace_id, slug, owner_id)`
含义:
- 同一 namespace 下,不同 owner 可以有相同 slug
- 同一 namespace 下,同一 owner 只能有一个相同 slug 的 skill
### 9.2 冲突规则设计原则
**核心原则**:只有 PUBLISHED 状态才会阻塞同名发布,但区分 visibility。
| 对方状态 | 我发布同名 PRIVATE | 我发布同名 PUBLIC | 说明 |
|---------|-------------------|------------------|------|
| UPLOADED | ✅ 允许 | ✅ 允许 | 多个 UPLOADED 可共存 |
| PENDING_REVIEW | ✅ 允许 | ✅ 允许 | 还未正式发布 |
| PRIVATE + PUBLISHED | ❌ 拒绝 | ❌ 拒绝 | 只允许一个正式私有版本 |
| PUBLIC + PUBLISHED | ❌ 拒绝 | ❌ 拒绝 | 市场已占用 |
### 9.3 冲突规则表(详细)
| 场景 | 是否允许 | 说明 |
|------|---------|------|
| 同 namespace同 slug同 owner | 允许(复用) | 新版本挂到已有 skill 下 |
| 同 namespace同 slug不同 owner对方只有 UPLOADED | 允许 | 多个 UPLOADED 可共存测试 |
| 同 namespace同 slug不同 owner对方只有 PENDING_REVIEW | 允许 | 还未正式发布 |
| 同 namespace同 slug不同 owner对方有 PRIVATE + PUBLISHED | 拒绝 | 只允许一个正式私有版本 |
| 同 namespace同 slug不同 owner对方有 PUBLIC/NAMESPACE_ONLY + PUBLISHED | 拒绝 | 市场已占用 |
| 不同 namespace同 slug | 允许 | namespace 隔离 |
### 9.4 完整流程示例
```
用户 A 发布 PRIVATE `ns/my-skill`
状态UPLOADED
用户 B 发布 PRIVATE `ns/my-skill`
状态UPLOADED ✅ 允许(多个 UPLOADED 可共存)
用户 A 确认发布 → PRIVATE + PUBLISHED ✅ 允许
用户 B 确认发布 → ❌ 被拒绝
错误信息error.skill.publish.nameConflict.private
用户 B 可以:
1. 改名发布
2. 等用户 A 删除/归档后再发布
3. 提交审核变成 PUBLIC如果 A 是 PRIVATE
```
### 9.5 代码改动
**文件**`SkillPublishService.java`
```java
// 冲突检查逻辑(第 230-242 行)
for (Skill existing : existingSkills) {
if (!existing.getOwnerId().equals(publisherId)) {
// 检查是否有 PUBLISHED 版本
boolean hasPublished = !skillVersionRepository
.findBySkillIdAndStatus(existing.getId(), SkillVersionStatus.PUBLISHED)
.isEmpty();
if (hasPublished) {
// PUBLISHED 版本存在,无论 visibility 如何都拒绝
// 因为只允许一个 PRIVATE + PUBLISHED 或 PUBLIC + PUBLISHED
if (existing.getVisibility() == SkillVisibility.PRIVATE) {
throw new DomainBadRequestException("error.skill.publish.nameConflict.private", skillSlug);
} else {
throw new DomainBadRequestException("error.skill.publish.nameConflict", skillSlug);
}
}
}
}
```
### 9.6 错误信息
| 错误码 | 说明 |
|-------|------|
| `error.skill.publish.nameConflict` | 已有同名 PUBLIC/NAMESPACE_ONLY skill 发布 |
| `error.skill.publish.nameConflict.private` | 已有同名 PRIVATE skill 正式发布 |
---
## 10. package_name / runtime 规则
### 10.1 当前实现
- `package_name` 不是 Core 的结构化字段
- 存储在 `skill_version.parsedMetadataJson` JSONB 字段中
- 由 skill 作者在 SKILL.md frontmatter 中定义
### 10.2 SaaS Adapter 职责
- 从 `parsedMetadataJson` 中提取 `package_name`
- 作为顶层字段返回给 AstronClaw
- 可选:检查跨 skill 的 package_name 唯一性
- 统一封装 `submit-review``confirm-publish`、删除、查询等 Core 能力,对 AstronClaw 暴露稳定接口
### 10.3 规则建议
| 规则 | 建议 |
|------|------|
| 格式 | 建议使用 `namespace__slug` 格式,避免冲突 |
| 跨版本稳定性 | 同一 skill 跨版本应保持 package_name 一致 |
| 唯一性 | SaaS Adapter 可检查并警告冲突,但不强制阻止 |
---
## 11. 代码改动清单
说明:
以下改动属于开源 `Core` 的规则实现,用于给 SaaS 封装层提供稳定能力基线;不等同于直接向 AstronClaw 暴露这些开源接口。
### 11.1 枚举新增
**文件**`SkillVersionStatus.java`
```java
public enum SkillVersionStatus {
DRAFT,
SCANNING,
SCAN_FAILED,
UPLOADED, // 新增
PENDING_REVIEW,
PUBLISHED,
REJECTED,
YANKED
}
```
### 11.2 发布逻辑改动
**文件**`SkillPublishService.java`
```java
// 第 279-285 行,改为
if (visibility == SkillVisibility.PRIVATE) {
version.setStatus(SkillVersionStatus.UPLOADED);
version.setPublishedAt(currentTime());
// 不创建审核任务
} else if (autoPublish) {
version.setStatus(SkillVersionStatus.PUBLISHED);
version.setPublishedAt(currentTime());
} else {
version.setStatus(SkillVersionStatus.PENDING_REVIEW);
// 创建审核任务
}
```
### 11.3 撤回审核改动
**文件**`SkillGovernanceService.java`
```java
// withdrawPendingVersion 方法,改为
skillVersion.setStatus(SkillVersionStatus.UPLOADED); // 原为 DRAFT
```
### 11.4 下载权限改动
**文件**`SkillDownloadService.java``SkillQueryService.java`
```java
// UPLOADED 和 PENDING_REVIEW 状态允许 owner 下载
private boolean canDownload(SkillVersion version, Skill skill, String currentUserId) {
return switch (version.getStatus()) {
case UPLOADED, PENDING_REVIEW -> skill.getOwnerId().equals(currentUserId);
case PUBLISHED -> true; // 按 visibility 判断
default -> false;
};
}
```
### 11.5 新增服务
**文件**`SkillReviewSubmitService.java`(新增)
- 实现 UPLOADED 版本提交审核逻辑
### 11.6 新增控制器
**文件**`SkillReviewSubmitController.java`(新增)
- 暴露 `POST /{namespace}/{slug}/submit-review` 接口
- 暴露 `POST /{namespace}/{slug}/confirm-publish` 接口
### 11.7 管理员可见性
**文件**`VisibilityChecker.java`
- SUPER_ADMIN 可以看到所有 skill包括 UPLOADED 状态
### 11.8 数据库迁移
**文件**:新增迁移脚本
- 更新 `skill_version_status` 枚举类型,添加 UPLOADED 值
---
## 12. 阻塞上线条件
| 问题 | 严重程度 | 状态 |
|------|---------|------|
| 新增 UPLOADED 状态 | 高 | 已完成 |
| PRIVATE skill 发布逻辑改动 | 高 | 已完成 |
| 提交审核接口 | 高 | 已完成 |
| 撤回审核后进入 UPLOADED | 中 | 已完成 |
| 同名冲突检查补全 | 中 | 已完成 |
| 管理员可见 UPLOADED skill | 低 | 已完成 |
| package_name 唯一性检查 | 低 | 可选SaaS Adapter 职责) |
---
## 13. 对老版本的影响
### 13.1 数据兼容性
| 影响点 | 分析 | 需要处理 |
|--------|------|---------|
| 老版本数据 | 不受影响,状态不变 | 否 |
| 数据库枚举 | 需添加 UPLOADED 值 | 是 |
| API 兼容性 | 新接口是新增,不影响老接口 | 否 |
### 13.2 状态流转影响
| 场景 | 老逻辑 | 新逻辑 | 影响 |
|------|--------|--------|------|
| 老版本撤回审核 | PENDING_REVIEW → DRAFT | PENDING_REVIEW → UPLOADED | 前端需适配新状态 |
| 老版本删除 | DRAFT/REJECTED/SCAN_FAILED 可删 | UPLOADED 也可删 | 需更新代码判断 |
### 13.3 代码改动点
**文件**`SkillGovernanceService.java`
**1. 删除版本逻辑**第163-166行
```java
// 原代码
if (version.getStatus() != SkillVersionStatus.DRAFT
&& version.getStatus() != SkillVersionStatus.REJECTED
&& version.getStatus() != SkillVersionStatus.SCAN_FAILED) {
throw new DomainBadRequestException("error.skill.version.delete.unsupported", version.getVersion());
}
// 改为:允许删除 UPLOADED 状态
if (version.getStatus() != SkillVersionStatus.DRAFT
&& version.getStatus() != SkillVersionStatus.REJECTED
&& version.getStatus() != SkillVersionStatus.SCAN_FAILED
&& version.getStatus() != SkillVersionStatus.UPLOADED) {
throw new DomainBadRequestException("error.skill.version.delete.unsupported", version.getVersion());
}
```
**2. 撤回审核逻辑**第245行
```java
// 原代码
version.setStatus(SkillVersionStatus.DRAFT);
// 改为
version.setStatus(SkillVersionStatus.UPLOADED);
```
### 13.4 前端适配
| 状态 | 前端展示建议 |
|------|-------------|
| UPLOADED | "已上传" 或 "待确认" |
| 可删除状态 | DRAFT、SCAN_FAILED、REJECTED、UPLOADED |
| 可编辑状态 | DRAFT、SCAN_FAILED、REJECTED |
### 13.5 迁移策略
1. **数据库迁移**:添加 UPLOADED 枚举值
2. **代码部署**:先部署后端,再部署前端
3. **老数据处理**:无需处理,老版本状态保持不变
4. **回滚方案**如需回滚UPLOADED 状态的版本按 DRAFT 处理

View file

@ -32,7 +32,7 @@ RUN mkdir -p /var/lib/skillhub/storage && \
USER app
EXPOSE 8080
HEALTHCHECK --interval=10s --timeout=3s \
HEALTHCHECK --interval=10s --timeout=3s --start-period=60s --retries=12 \
CMD wget -qO- http://localhost:8080/actuator/health || exit 1
ENTRYPOINT ["java", "-XX:MaxRAMPercentage=75.0", "-jar", "app.jar"]

View file

@ -13,7 +13,7 @@ RUN chown -R app:app /app
USER app
EXPOSE 8080
HEALTHCHECK --interval=10s --timeout=3s \
HEALTHCHECK --interval=10s --timeout=3s --start-period=60s --retries=12 \
CMD wget -qO- http://localhost:8080/actuator/health || exit 1
ENTRYPOINT ["java", "-XX:MaxRAMPercentage=75.0", "-jar", "app.jar"]

View file

@ -28,6 +28,7 @@ import java.util.List;
import java.util.Map;
import org.slf4j.MDC;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import org.springframework.web.multipart.MultipartFile;
/**
@ -37,6 +38,8 @@ import org.springframework.web.multipart.MultipartFile;
@Service
public class ClawHubCompatAppService {
private static final String GLOBAL_NAMESPACE = "global";
private final CanonicalSlugMapper mapper;
private final SkillSearchAppService skillSearchAppService;
private final SkillQueryService skillQueryService;
@ -94,7 +97,8 @@ public class ClawHubCompatAppService {
String hash,
String userId,
Map<Long, NamespaceRole> userNsRoles) {
SkillCoordinate coord = resolveQueryCoordinate(slug);
SkillCoordinate coord = resolveQueryCoordinate(slug, userId, userNsRoles);
Map<Long, NamespaceRole> roles = normalizeRoles(userNsRoles);
SkillQueryService.ResolvedVersionDTO resolved = skillQueryService.resolveVersion(
coord.namespace(),
@ -103,7 +107,7 @@ public class ClawHubCompatAppService {
"latest".equals(version) ? "latest" : null,
hash,
userId,
userNsRoles != null ? userNsRoles : Map.of()
roles
);
return toResolveResponse(resolved);
}
@ -132,23 +136,37 @@ public class ClawHubCompatAppService {
: "/api/v1/skills/" + coord.namespace() + "/" + coord.slug() + "/versions/" + version + "/download";
}
public String downloadLocationByQuery(String slug, String version) {
SkillCoordinate coord = resolveQueryCoordinate(slug);
public String downloadLocationByQuery(String slug,
String version,
String userId,
Map<Long, NamespaceRole> userNsRoles) {
SkillCoordinate coord = resolveQueryCoordinate(slug, userId, userNsRoles);
return "latest".equals(version)
? "/api/v1/skills/" + coord.namespace() + "/" + coord.slug() + "/download"
: "/api/v1/skills/" + coord.namespace() + "/" + coord.slug() + "/versions/" + version + "/download";
}
private SkillCoordinate resolveQueryCoordinate(String slug) {
private SkillCoordinate resolveQueryCoordinate(String slug,
String userId,
Map<Long, NamespaceRole> userNsRoles) {
if (slug != null && slug.contains("--")) {
return mapper.fromCanonical(slug);
}
CompatSkillLookupService.CompatSkillContext context;
try {
CompatSkillLookupService.CompatSkillContext context = compatSkillLookupService.findByLegacySlug(slug);
return new SkillCoordinate(context.namespace().getSlug(), context.skill().getSlug());
context = compatSkillLookupService.findByLegacySlug(slug);
} catch (DomainNotFoundException ex) {
return mapper.fromCanonical(slug);
}
Map<Long, NamespaceRole> roles = normalizeRoles(userNsRoles);
if (!compatSkillLookupService.canAccess(context.skill(), userId, roles)) {
throw new DomainNotFoundException("error.skill.notFound", slug);
}
return new SkillCoordinate(context.namespace().getSlug(), context.skill().getSlug());
}
private Map<Long, NamespaceRole> normalizeRoles(Map<Long, NamespaceRole> userNsRoles) {
return userNsRoles != null ? userNsRoles : Map.of();
}
public ClawHubSkillListResponse listSkills(int page,
@ -182,11 +200,18 @@ public class ClawHubCompatAppService {
}
public ClawHubSkillResponse getSkill(String canonicalSlug, String userId) {
return getSkill(canonicalSlug, userId, Map.of());
}
public ClawHubSkillResponse getSkill(String canonicalSlug,
String userId,
Map<Long, NamespaceRole> userNsRoles) {
SkillCoordinate coord = mapper.fromCanonical(canonicalSlug);
CompatSkillLookupService.CompatSkillContext context = compatSkillLookupService.resolveVisible(
coord.namespace(),
coord.slug(),
userId
userId,
userNsRoles != null ? userNsRoles : Map.of()
);
SkillVersion latestVersionEntity = context.latestVersion().orElse(null);
@ -263,17 +288,19 @@ public class ClawHubCompatAppService {
public ClawHubPublishResponse publishSkill(String payloadJson,
MultipartFile[] files,
boolean confirmWarnings,
PlatformPrincipal principal,
String clientIp,
String userAgent) throws IOException {
MultipartPackageExtractor.ExtractedPackage extracted = multipartPackageExtractor.extract(files, payloadJson);
String namespace = determineNamespace(principal, extracted.payload());
String namespace = determineNamespace(extracted.payload());
SkillPublishService.PublishResult result = skillPublishService.publishFromEntries(
namespace,
extracted.entries(),
principal.userId(),
SkillVisibility.PUBLIC,
principal.platformRoles()
principal.platformRoles(),
confirmWarnings
);
recordCompatPublishAudit(principal.userId(), result.version().getId(), clientIp, userAgent,
"{\"namespace\":\"" + namespace + "\",\"slug\":\"" + extracted.payload().slug() + "\"}");
@ -282,6 +309,7 @@ public class ClawHubCompatAppService {
public ClawHubPublishResponse publish(MultipartFile file,
String namespace,
boolean confirmWarnings,
PlatformPrincipal principal,
String clientIp,
String userAgent) throws IOException {
@ -290,7 +318,8 @@ public class ClawHubCompatAppService {
zipPackageExtractor.extract(file),
principal.userId(),
SkillVisibility.PUBLIC,
principal.platformRoles()
principal.platformRoles(),
confirmWarnings
);
recordCompatPublishAudit(principal.userId(), result.version().getId(), clientIp, userAgent,
"{\"namespace\":\"" + namespace + "\"}");
@ -367,8 +396,28 @@ public class ClawHubCompatAppService {
);
}
private String determineNamespace(PlatformPrincipal principal, MultipartPackageExtractor.PublishPayload payload) {
return "global";
private String determineNamespace(MultipartPackageExtractor.PublishPayload payload) {
if (payload == null) {
return GLOBAL_NAMESPACE;
}
if (StringUtils.hasText(payload.namespace())) {
return normalizeNamespace(payload.namespace());
}
if (StringUtils.hasText(payload.slug()) && payload.slug().contains("--")) {
return mapper.fromCanonical(payload.slug()).namespace();
}
return GLOBAL_NAMESPACE;
}
private String normalizeNamespace(String namespace) {
String trimmed = namespace.trim();
if (trimmed.startsWith("@")) {
return trimmed.substring(1);
}
return trimmed;
}
private void recordCompatPublishAudit(String userId,

View file

@ -82,8 +82,10 @@ public class ClawHubCompatController {
@RateLimit(category = "download", authenticated = 60, anonymous = 20)
@GetMapping("/download")
public ResponseEntity<Void> downloadByQuery(@RequestParam String slug,
@RequestParam(defaultValue = "latest") String version) {
return redirect(clawHubCompatAppService.downloadLocationByQuery(slug, version));
@RequestParam(defaultValue = "latest") String version,
@RequestAttribute(value = "userId", required = false) String userId,
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> userNsRoles) {
return redirect(clawHubCompatAppService.downloadLocationByQuery(slug, version, userId, userNsRoles));
}
@RateLimit(category = "skills", authenticated = 60, anonymous = 20)
@ -99,8 +101,9 @@ public class ClawHubCompatController {
@RateLimit(category = "skills", authenticated = 60, anonymous = 20)
@GetMapping("/skills/{canonicalSlug}")
public ClawHubSkillResponse getSkill(@PathVariable String canonicalSlug,
@RequestAttribute(value = "userId", required = false) String userId) {
return clawHubCompatAppService.getSkill(canonicalSlug, userId);
@RequestAttribute(value = "userId", required = false) String userId,
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> userNsRoles) {
return clawHubCompatAppService.getSkill(canonicalSlug, userId, userNsRoles);
}
@RateLimit(category = "skills", authenticated = 60, anonymous = 20)
@ -135,11 +138,13 @@ public class ClawHubCompatController {
@PostMapping("/skills")
public ClawHubPublishResponse publishSkill(@RequestParam("payload") String payloadJson,
@RequestParam("files") MultipartFile[] files,
@RequestParam(value = "confirmWarnings", defaultValue = "false") boolean confirmWarnings,
@AuthenticationPrincipal PlatformPrincipal principal,
HttpServletRequest request) throws IOException {
return clawHubCompatAppService.publishSkill(
payloadJson,
files,
confirmWarnings,
principal,
request.getRemoteAddr(),
request.getHeader("User-Agent")
@ -150,11 +155,13 @@ public class ClawHubCompatController {
@PostMapping("/publish")
public ClawHubPublishResponse publish(@RequestParam("file") MultipartFile file,
@RequestParam("namespace") String namespace,
@RequestParam(value = "confirmWarnings", defaultValue = "false") boolean confirmWarnings,
@AuthenticationPrincipal PlatformPrincipal principal,
HttpServletRequest request) throws IOException {
return clawHubCompatAppService.publish(
file,
namespace,
confirmWarnings,
principal,
request.getRemoteAddr(),
request.getHeader("User-Agent")

View file

@ -83,7 +83,8 @@ public class ClawHubRegistryFacade {
CompatSkillLookupService.CompatSkillContext context = compatSkillLookupService.resolveVisible(
coordinate.namespace(),
coordinate.slug(),
userId
userId,
normalizeRoles(userNsRoles)
);
Skill skill = context.skill();

View file

@ -1,13 +1,16 @@
package com.iflytek.skillhub.compat;
import com.iflytek.skillhub.domain.namespace.Namespace;
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
import com.iflytek.skillhub.domain.namespace.NamespaceRepository;
import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException;
import com.iflytek.skillhub.domain.skill.Skill;
import com.iflytek.skillhub.domain.skill.SkillRepository;
import com.iflytek.skillhub.domain.skill.SkillVersion;
import com.iflytek.skillhub.domain.skill.SkillVersionRepository;
import com.iflytek.skillhub.domain.skill.VisibilityChecker;
import com.iflytek.skillhub.domain.skill.service.SkillSlugResolutionService;
import java.util.Map;
import java.util.Optional;
import org.springframework.stereotype.Service;
@ -22,15 +25,18 @@ public class CompatSkillLookupService {
private final NamespaceRepository namespaceRepository;
private final SkillVersionRepository skillVersionRepository;
private final SkillSlugResolutionService skillSlugResolutionService;
private final VisibilityChecker visibilityChecker;
public CompatSkillLookupService(SkillRepository skillRepository,
NamespaceRepository namespaceRepository,
SkillVersionRepository skillVersionRepository,
SkillSlugResolutionService skillSlugResolutionService) {
SkillSlugResolutionService skillSlugResolutionService,
VisibilityChecker visibilityChecker) {
this.skillRepository = skillRepository;
this.namespaceRepository = namespaceRepository;
this.skillVersionRepository = skillVersionRepository;
this.skillSlugResolutionService = skillSlugResolutionService;
this.visibilityChecker = visibilityChecker;
}
public CompatSkillContext findByLegacySlug(String slug) {
@ -41,10 +47,28 @@ public class CompatSkillLookupService {
return new CompatSkillContext(namespace, skill, findLatestVersion(skill));
}
public boolean canAccess(Skill skill, String currentUserId, Map<Long, NamespaceRole> userNsRoles) {
if (skill == null) {
return false;
}
Map<Long, NamespaceRole> roles = userNsRoles != null ? userNsRoles : Map.of();
return visibilityChecker.canAccess(skill, currentUserId, roles);
}
public CompatSkillContext resolveVisible(String namespaceSlug, String skillSlug, String currentUserId) {
return resolveVisible(namespaceSlug, skillSlug, currentUserId, Map.of());
}
public CompatSkillContext resolveVisible(String namespaceSlug,
String skillSlug,
String currentUserId,
Map<Long, NamespaceRole> userNsRoles) {
Namespace namespace = namespaceRepository.findBySlug(namespaceSlug)
.orElseThrow(() -> new DomainNotFoundException("error.namespace.notFound", namespaceSlug));
Skill skill = resolveVisibleSkill(namespace.getId(), skillSlug, currentUserId);
if (!canAccess(skill, currentUserId, userNsRoles)) {
throw new DomainNotFoundException("error.skill.notFound", skillSlug);
}
return new CompatSkillContext(namespace, skill, findLatestVersion(skill));
}

View file

@ -1,6 +1,7 @@
package com.iflytek.skillhub.controller;
import com.iflytek.skillhub.auth.local.LocalAuthService;
import com.iflytek.skillhub.auth.local.PasswordResetService;
import com.iflytek.skillhub.auth.exception.AuthFlowException;
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
import com.iflytek.skillhub.auth.session.PlatformSessionService;
@ -10,6 +11,8 @@ import com.iflytek.skillhub.dto.AuthMeResponse;
import com.iflytek.skillhub.dto.ChangePasswordRequest;
import com.iflytek.skillhub.dto.LocalLoginRequest;
import com.iflytek.skillhub.dto.LocalRegisterRequest;
import com.iflytek.skillhub.dto.PasswordResetConfirmRequest;
import com.iflytek.skillhub.dto.PasswordResetRequestDto;
import com.iflytek.skillhub.exception.UnauthorizedException;
import com.iflytek.skillhub.metrics.SkillHubMetrics;
import com.iflytek.skillhub.ratelimit.RateLimit;
@ -34,17 +37,20 @@ public class LocalAuthController extends BaseApiController {
private final SkillHubMetrics skillHubMetrics;
private final PlatformSessionService platformSessionService;
private final AuthFailureThrottleService authFailureThrottleService;
private final PasswordResetService passwordResetService;
public LocalAuthController(ApiResponseFactory responseFactory,
LocalAuthService localAuthService,
SkillHubMetrics skillHubMetrics,
PlatformSessionService platformSessionService,
AuthFailureThrottleService authFailureThrottleService) {
AuthFailureThrottleService authFailureThrottleService,
PasswordResetService passwordResetService) {
super(responseFactory);
this.localAuthService = localAuthService;
this.skillHubMetrics = skillHubMetrics;
this.platformSessionService = platformSessionService;
this.authFailureThrottleService = authFailureThrottleService;
this.passwordResetService = passwordResetService;
}
@PostMapping("/register")
@ -92,6 +98,20 @@ public class LocalAuthController extends BaseApiController {
return ok("response.success.updated", null);
}
@PostMapping("/password-reset/request")
@RateLimit(category = "auth-password-reset-request", authenticated = 8, anonymous = 5, windowSeconds = 300)
public ApiResponse<Void> requestPasswordReset(@Valid @RequestBody PasswordResetRequestDto request) {
passwordResetService.requestPasswordReset(request.email());
return ok("response.auth.password.reset.requested", null);
}
@PostMapping("/password-reset/confirm")
@RateLimit(category = "auth-password-reset-confirm", authenticated = 10, anonymous = 10, windowSeconds = 300)
public ApiResponse<Void> confirmPasswordReset(@Valid @RequestBody PasswordResetConfirmRequest request) {
passwordResetService.confirmPasswordReset(request.email(), request.code(), request.newPassword());
return ok("response.auth.password.reset.confirmed", null);
}
private String resolveClientIp(HttpServletRequest request) {
String ip = request.getHeader("X-Forwarded-For");
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {

View file

@ -1,6 +1,7 @@
package com.iflytek.skillhub.controller.admin;
import com.iflytek.skillhub.controller.BaseApiController;
import com.iflytek.skillhub.auth.local.PasswordResetService;
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
import com.iflytek.skillhub.dto.AdminUserMutationResponse;
import com.iflytek.skillhub.dto.AdminUserRoleUpdateRequest;
@ -9,6 +10,7 @@ import com.iflytek.skillhub.dto.AdminUserSummaryResponse;
import com.iflytek.skillhub.dto.ApiResponse;
import com.iflytek.skillhub.dto.ApiResponseFactory;
import com.iflytek.skillhub.dto.PageResponse;
import com.iflytek.skillhub.exception.UnauthorizedException;
import com.iflytek.skillhub.service.AdminUserAppService;
import jakarta.validation.Valid;
import org.springframework.security.access.prepost.PreAuthorize;
@ -24,11 +26,14 @@ import org.springframework.web.bind.annotation.*;
public class UserManagementController extends BaseApiController {
private final AdminUserAppService adminUserAppService;
private final PasswordResetService passwordResetService;
public UserManagementController(AdminUserAppService adminUserAppService,
PasswordResetService passwordResetService,
ApiResponseFactory responseFactory) {
super(responseFactory);
this.adminUserAppService = adminUserAppService;
this.passwordResetService = passwordResetService;
}
@GetMapping
@ -76,4 +81,15 @@ public class UserManagementController extends BaseApiController {
public ApiResponse<AdminUserMutationResponse> enableUser(@PathVariable String userId) {
return ok("response.success.updated", adminUserAppService.updateUserStatus(userId, "ACTIVE"));
}
@PostMapping("/{userId}/password-reset")
@PreAuthorize("hasAnyRole('USER_ADMIN', 'SUPER_ADMIN')")
public ApiResponse<Void> triggerPasswordReset(@PathVariable String userId,
@AuthenticationPrincipal PlatformPrincipal principal) {
if (principal == null) {
throw new UnauthorizedException("error.auth.required");
}
passwordResetService.adminTriggerPasswordReset(userId, principal.userId());
return ok("response.auth.password.reset.requested", null);
}
}

View file

@ -55,8 +55,10 @@ public class NamespaceController extends BaseApiController {
}
@GetMapping("/namespaces")
public ApiResponse<PageResponse<NamespaceResponse>> listNamespaces(Pageable pageable) {
return ok("response.success.read", namespacePortalQueryAppService.listNamespaces(pageable));
public ApiResponse<PageResponse<NamespaceResponse>> listNamespaces(
Pageable pageable,
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> userNsRoles) {
return ok("response.success.read", namespacePortalQueryAppService.listNamespaces(pageable, userNsRoles));
}
@GetMapping("/me/namespaces")
@ -68,7 +70,7 @@ public class NamespaceController extends BaseApiController {
@GetMapping("/namespaces/{slug}")
public ApiResponse<NamespaceResponse> getNamespace(@PathVariable String slug,
@RequestAttribute(value = "userId", required = false) String userId,
@RequestAttribute("userId") String userId,
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> userNsRoles) {
return ok("response.success.read",
namespacePortalQueryAppService.getNamespace(slug, userId, userNsRoles));

View file

@ -103,6 +103,10 @@ public class SecurityAuditController extends BaseApiController {
return true;
}
Map<Long, NamespaceRole> namespaceRoles = userNsRoles != null ? userNsRoles : Map.of();
NamespaceRole namespaceRole = namespaceRoles.get(skill.getNamespaceId());
if (namespaceRole == NamespaceRole.ADMIN || namespaceRole == NamespaceRole.OWNER) {
return true;
}
return visibilityChecker.canAccess(skill, principal.userId(), namespaceRoles);
}

View file

@ -5,8 +5,10 @@ import com.iflytek.skillhub.domain.namespace.NamespaceRole;
import com.iflytek.skillhub.dto.AdminSkillActionRequest;
import com.iflytek.skillhub.dto.ApiResponse;
import com.iflytek.skillhub.dto.ApiResponseFactory;
import com.iflytek.skillhub.dto.ConfirmPublishRequest;
import com.iflytek.skillhub.dto.SkillLifecycleMutationResponse;
import com.iflytek.skillhub.dto.SkillVersionRereleaseRequest;
import com.iflytek.skillhub.dto.SubmitReviewRequest;
import com.iflytek.skillhub.service.AuditRequestContext;
import com.iflytek.skillhub.service.GovernanceWorkflowAppService;
import jakarta.validation.Valid;
@ -118,4 +120,39 @@ public class SkillLifecycleController extends BaseApiController {
userNsRoles,
AuditRequestContext.from(httpRequest)));
}
@PostMapping("/{namespace}/{slug}/submit-review")
public ApiResponse<SkillLifecycleMutationResponse> submitForReview(@PathVariable String namespace,
@PathVariable String slug,
@Valid @RequestBody SubmitReviewRequest request,
@RequestAttribute("userId") String userId,
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> userNsRoles,
HttpServletRequest httpRequest) {
return ok("response.success.updated",
governanceWorkflowAppService.submitForReview(
namespace,
slug,
request.version(),
request.targetVisibility(),
userId,
userNsRoles,
AuditRequestContext.from(httpRequest)));
}
@PostMapping("/{namespace}/{slug}/confirm-publish")
public ApiResponse<SkillLifecycleMutationResponse> confirmPublish(@PathVariable String namespace,
@PathVariable String slug,
@Valid @RequestBody ConfirmPublishRequest request,
@RequestAttribute("userId") String userId,
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> userNsRoles,
HttpServletRequest httpRequest) {
return ok("response.success.updated",
governanceWorkflowAppService.confirmPublish(
namespace,
slug,
request.version(),
userId,
userNsRoles,
AuditRequestContext.from(httpRequest)));
}
}

View file

@ -53,6 +53,7 @@ public class SkillPublishController extends BaseApiController {
@PathVariable String namespace,
@RequestParam("file") MultipartFile file,
@RequestParam("visibility") String visibility,
@RequestParam(value = "confirmWarnings", defaultValue = "false") boolean confirmWarnings,
@AuthenticationPrincipal PlatformPrincipal principal) throws IOException {
SkillVisibility skillVisibility = SkillVisibility.valueOf(visibility.toUpperCase());
@ -69,7 +70,8 @@ public class SkillPublishController extends BaseApiController {
entries,
principal.userId(),
skillVisibility,
principal.platformRoles()
principal.platformRoles(),
confirmWarnings
);
PublishResponse response = new PublishResponse(

View file

@ -30,6 +30,7 @@ public class MultipartPackageExtractor {
}
public record PublishPayload(
String namespace,
String slug,
String displayName,
String version,

View file

@ -136,7 +136,7 @@ public class SkillPackageArchiveExtractor {
if (lower.endsWith(".css")) return "text/css";
if (lower.endsWith(".csv")) return "text/csv";
if (lower.endsWith(".xml")) return "application/xml";
if (lower.endsWith(".js")) return "text/javascript";
if (lower.endsWith(".js") || lower.endsWith(".cjs") || lower.endsWith(".mjs")) return "text/javascript";
if (lower.endsWith(".ts")) return "text/typescript";
if (lower.endsWith(".sh") || lower.endsWith(".bash") || lower.endsWith(".zsh")) return "text/x-shellscript";
if (lower.endsWith(".png")) return "image/png";

View file

@ -0,0 +1,11 @@
package com.iflytek.skillhub.dto;
import jakarta.validation.constraints.NotBlank;
/**
* Request to confirm publish for a PRIVATE skill version.
*/
public record ConfirmPublishRequest(
@NotBlank(message = "Version is required")
String version
) {}

View file

@ -8,6 +8,7 @@ public record LocalRegisterRequest(
String username,
@NotBlank(message = "{validation.auth.local.password.notBlank}")
String password,
@NotBlank(message = "{validation.auth.local.email.notBlank}")
@Email(message = "{validation.auth.local.email.invalid}")
String email
) {}

View file

@ -0,0 +1,18 @@
package com.iflytek.skillhub.dto;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
public record PasswordResetConfirmRequest(
@NotBlank(message = "{validation.auth.password.reset.email.notBlank}")
@Email(message = "{validation.auth.password.reset.email.invalid}")
@Pattern(regexp = "^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$", message = "{validation.auth.password.reset.email.invalid}")
String email,
@NotBlank(message = "{validation.auth.password.reset.code.notBlank}")
@Pattern(regexp = "^\\d{6}$", message = "{validation.auth.password.reset.code.invalid}")
String code,
@NotBlank(message = "{validation.auth.password.reset.newPassword.notBlank}")
String newPassword
) {
}

View file

@ -0,0 +1,13 @@
package com.iflytek.skillhub.dto;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
public record PasswordResetRequestDto(
@NotBlank(message = "{validation.auth.password.reset.email.notBlank}")
@Email(message = "{validation.auth.password.reset.email.invalid}")
@Pattern(regexp = "^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$", message = "{validation.auth.password.reset.email.invalid}")
String email
) {
}

View file

@ -4,6 +4,7 @@ import jakarta.validation.constraints.NotBlank;
public record SkillVersionRereleaseRequest(
@NotBlank(message = "{validation.required}")
String targetVersion
String targetVersion,
boolean confirmWarnings
) {
}

View file

@ -0,0 +1,16 @@
package com.iflytek.skillhub.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
/**
* Request to submit a skill version for review.
*/
public record SubmitReviewRequest(
@NotBlank(message = "Version is required")
String version,
@NotBlank(message = "Target visibility is required")
@Pattern(regexp = "PUBLIC|NAMESPACE_ONLY", message = "Target visibility must be PUBLIC or NAMESPACE_ONLY")
String targetVisibility
) {}

View file

@ -73,6 +73,7 @@ public class AuthContextFilter extends OncePerRequestFilter {
return;
}
request.setAttribute("userId", principal.userId());
request.setAttribute("platformRoles", principal.platformRoles() != null ? principal.platformRoles() : java.util.Set.of());
Map<Long, NamespaceRole> userNsRoles = namespaceMemberRepository.findByUserId(principal.userId()).stream()
.collect(Collectors.toMap(
NamespaceMember::getNamespaceId,

View file

@ -254,4 +254,36 @@ public class GovernanceWorkflowAppService {
AuditRequestContext auditContext) {
return namespacePortalCommandAppService.restoreNamespace(slug, userId, auditContext);
}
public SkillLifecycleMutationResponse submitForReview(String namespace,
String slug,
String version,
String targetVisibility,
String userId,
Map<Long, NamespaceRole> userNsRoles,
AuditRequestContext auditContext) {
return skillLifecycleAppService.submitForReview(
namespace,
slug,
version,
targetVisibility,
userId,
userNsRoles,
auditContext);
}
public SkillLifecycleMutationResponse confirmPublish(String namespace,
String slug,
String version,
String userId,
Map<Long, NamespaceRole> userNsRoles,
AuditRequestContext auditContext) {
return skillLifecycleAppService.confirmPublish(
namespace,
slug,
version,
userId,
userNsRoles,
auditContext);
}
}

View file

@ -8,6 +8,7 @@ import com.iflytek.skillhub.domain.namespace.NamespaceRepository;
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
import com.iflytek.skillhub.domain.namespace.NamespaceService;
import com.iflytek.skillhub.domain.namespace.NamespaceStatus;
import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException;
import com.iflytek.skillhub.domain.user.UserAccount;
import com.iflytek.skillhub.domain.user.UserAccountRepository;
import com.iflytek.skillhub.dto.MemberResponse;
@ -20,6 +21,8 @@ import java.util.Map;
import java.util.function.Function;
import java.util.stream.Collectors;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@ -50,9 +53,31 @@ public class NamespacePortalQueryAppService {
}
@Transactional(readOnly = true)
public PageResponse<NamespaceResponse> listNamespaces(Pageable pageable) {
Page<Namespace> namespaces = namespaceRepository.findByStatus(NamespaceStatus.ACTIVE, pageable);
return PageResponse.from(namespaces.map(NamespaceResponse::from));
public PageResponse<NamespaceResponse> listNamespaces(Pageable pageable, Map<Long, NamespaceRole> userNamespaceRoles) {
Map<Long, NamespaceRole> namespaceRoles = userNamespaceRoles != null ? userNamespaceRoles : Map.of();
if (namespaceRoles.isEmpty()) {
Page<NamespaceResponse> empty = new PageImpl<>(
List.of(),
PageRequest.of(pageable.getPageNumber(), pageable.getPageSize()),
0
);
return PageResponse.from(empty);
}
List<Namespace> scopedNamespaces = namespaceRepository.findByIdIn(namespaceRoles.keySet().stream().toList()).stream()
.filter(namespace -> namespace.getStatus() == NamespaceStatus.ACTIVE)
.sorted(Comparator.comparing(Namespace::getSlug))
.toList();
int fromIndex = Math.min((int) pageable.getOffset(), scopedNamespaces.size());
int toIndex = Math.min(fromIndex + pageable.getPageSize(), scopedNamespaces.size());
Page<NamespaceResponse> page = new PageImpl<>(
scopedNamespaces.subList(fromIndex, toIndex).stream()
.map(NamespaceResponse::from)
.toList(),
pageable,
scopedNamespaces.size()
);
return PageResponse.from(page);
}
@Transactional(readOnly = true)
@ -73,10 +98,14 @@ public class NamespacePortalQueryAppService {
@Transactional(readOnly = true)
public NamespaceResponse getNamespace(String slug, String userId, Map<Long, NamespaceRole> userNamespaceRoles) {
Map<Long, NamespaceRole> namespaceRoles = userNamespaceRoles != null ? userNamespaceRoles : Map.of();
Namespace namespace = namespaceService.getNamespaceBySlugForRead(
slug,
userId,
userNamespaceRoles != null ? userNamespaceRoles : Map.of());
namespaceRoles);
if (!namespaceRoles.containsKey(namespace.getId())) {
throw new DomainForbiddenException("error.namespace.membership.required");
}
return NamespaceResponse.from(namespace);
}

View file

@ -11,6 +11,7 @@ import com.iflytek.skillhub.domain.skill.SkillVersion;
import com.iflytek.skillhub.domain.skill.SkillVersionRepository;
import com.iflytek.skillhub.domain.skill.service.SkillGovernanceService;
import com.iflytek.skillhub.domain.skill.service.SkillPublishService;
import com.iflytek.skillhub.domain.skill.service.SkillReviewSubmitService;
import com.iflytek.skillhub.domain.skill.service.SkillSlugResolutionService;
import com.iflytek.skillhub.dto.AdminSkillActionRequest;
import com.iflytek.skillhub.dto.SkillLifecycleMutationResponse;
@ -31,6 +32,7 @@ public class SkillLifecycleAppService {
private final SkillGovernanceService skillGovernanceService;
private final ReviewService reviewService;
private final SkillPublishService skillPublishService;
private final SkillReviewSubmitService skillReviewSubmitService;
private final AuditLogService auditLogService;
private final SkillSlugResolutionService skillSlugResolutionService;
@ -39,6 +41,7 @@ public class SkillLifecycleAppService {
SkillGovernanceService skillGovernanceService,
ReviewService reviewService,
SkillPublishService skillPublishService,
SkillReviewSubmitService skillReviewSubmitService,
AuditLogService auditLogService,
SkillSlugResolutionService skillSlugResolutionService) {
this.namespaceRepository = namespaceRepository;
@ -46,6 +49,7 @@ public class SkillLifecycleAppService {
this.skillGovernanceService = skillGovernanceService;
this.reviewService = reviewService;
this.skillPublishService = skillPublishService;
this.skillReviewSubmitService = skillReviewSubmitService;
this.auditLogService = auditLogService;
this.skillSlugResolutionService = skillSlugResolutionService;
}
@ -150,7 +154,8 @@ public class SkillLifecycleAppService {
skillVersion.getVersion(),
targetVersion,
userId,
normalizeRoles(userNamespaceRoles)
normalizeRoles(userNamespaceRoles),
request.confirmWarnings()
);
auditLogService.record(
userId,
@ -171,6 +176,74 @@ public class SkillLifecycleAppService {
);
}
@Transactional
public SkillLifecycleMutationResponse submitForReview(String namespace,
String slug,
String version,
String targetVisibility,
String userId,
Map<Long, NamespaceRole> userNamespaceRoles,
AuditRequestContext auditContext) {
Skill skill = findSkill(namespace, slug, userId);
SkillVersion skillVersion = findVersion(skill.getId(), version);
skillReviewSubmitService.submitForReview(
skill.getId(),
skillVersion.getId(),
com.iflytek.skillhub.domain.skill.SkillVisibility.valueOf(targetVisibility),
userId,
normalizeRoles(userNamespaceRoles)
);
auditLogService.record(
userId,
"SUBMIT_REVIEW",
"SKILL_VERSION",
skillVersion.getId(),
null,
auditContext.clientIp(),
auditContext.userAgent(),
"{\"version\":\"" + version.replace("\"", "\\\"") + "\",\"targetVisibility\":\"" + targetVisibility + "\"}"
);
return new SkillLifecycleMutationResponse(
skill.getId(),
skillVersion.getId(),
"SUBMIT_REVIEW",
"PENDING_REVIEW"
);
}
@Transactional
public SkillLifecycleMutationResponse confirmPublish(String namespace,
String slug,
String version,
String userId,
Map<Long, NamespaceRole> userNamespaceRoles,
AuditRequestContext auditContext) {
Skill skill = findSkill(namespace, slug, userId);
SkillVersion skillVersion = findVersion(skill.getId(), version);
skillReviewSubmitService.confirmPublish(
skill.getId(),
skillVersion.getId(),
userId,
normalizeRoles(userNamespaceRoles)
);
auditLogService.record(
userId,
"CONFIRM_PUBLISH",
"SKILL_VERSION",
skillVersion.getId(),
null,
auditContext.clientIp(),
auditContext.userAgent(),
"{\"version\":\"" + version.replace("\"", "\\\"") + "\"}"
);
return new SkillLifecycleMutationResponse(
skill.getId(),
skillVersion.getId(),
"CONFIRM_PUBLISH",
"PUBLISHED"
);
}
private Skill findSkill(String namespaceSlug, String skillSlug, String currentUserId) {
String cleanNamespace = namespaceSlug.startsWith("@") ? namespaceSlug.substring(1) : namespaceSlug;
Namespace namespace = namespaceRepository.findBySlug(cleanNamespace)

View file

@ -61,6 +61,17 @@ spring:
multipart:
max-file-size: 100MB
max-request-size: 100MB
mail:
host: ${SPRING_MAIL_HOST:localhost}
port: ${SPRING_MAIL_PORT:25}
username: ${SPRING_MAIL_USERNAME:}
password: ${SPRING_MAIL_PASSWORD:}
properties:
mail:
smtp:
auth: ${SPRING_MAIL_SMTP_AUTH:false}
starttls:
enable: ${SPRING_MAIL_SMTP_STARTTLS_ENABLE:false}
skillhub:
auth:
@ -70,6 +81,10 @@ skillhub:
enabled: ${SKILLHUB_AUTH_DIRECT_ENABLED:false}
session-bootstrap:
enabled: ${SKILLHUB_AUTH_SESSION_BOOTSTRAP_ENABLED:false}
password-reset:
code-expiry: ${SKILLHUB_AUTH_PASSWORD_RESET_CODE_EXPIRY:PT10M}
email-from-address: ${SKILLHUB_AUTH_PASSWORD_RESET_FROM_ADDRESS:noreply@skillhub.local}
email-from-name: ${SKILLHUB_AUTH_PASSWORD_RESET_FROM_NAME:SkillHub}
public:
base-url: ${SKILLHUB_PUBLIC_BASE_URL:}
access-policy:
@ -168,10 +183,13 @@ skillhub:
email: ${BOOTSTRAP_ADMIN_EMAIL:admin@skillhub.local}
management:
health:
mail:
enabled: ${MANAGEMENT_HEALTH_MAIL_ENABLED:false}
endpoints:
web:
exposure:
include: health,info,prometheus,metrics
include: health,info
endpoint:
health:
show-details: when-authorized
@ -180,4 +198,4 @@ management:
application: skillhub
export:
prometheus:
enabled: true
enabled: false

View file

@ -0,0 +1,20 @@
-- Password reset verification code records for self-service and admin-triggered flows
CREATE TABLE password_reset_request (
id BIGSERIAL PRIMARY KEY,
user_id VARCHAR(128) NOT NULL REFERENCES user_account(id) ON DELETE CASCADE,
email VARCHAR(255) NOT NULL,
code_hash VARCHAR(255) NOT NULL,
expires_at TIMESTAMPTZ NOT NULL,
consumed_at TIMESTAMPTZ,
requested_by_admin BOOLEAN NOT NULL DEFAULT FALSE,
requested_by_user_id VARCHAR(128) REFERENCES user_account(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_password_reset_request_user_id ON password_reset_request(user_id);
CREATE INDEX idx_password_reset_request_expires_at ON password_reset_request(expires_at);
COMMENT ON TABLE password_reset_request IS 'Stores password reset verification code requests for local account recovery';
COMMENT ON COLUMN password_reset_request.code_hash IS 'BCrypt hash of the one-time verification code';
COMMENT ON COLUMN password_reset_request.requested_by_admin IS 'True when the reset is triggered by an administrator';
COMMENT ON COLUMN password_reset_request.requested_by_user_id IS 'Admin user who triggered the reset, if applicable';

View file

@ -16,6 +16,7 @@ validation.member.userId.notNull=User ID is required
validation.member.role.notNull=Role is required
validation.auth.local.username.notBlank=Username cannot be blank
validation.auth.local.password.notBlank=Password cannot be blank
validation.auth.local.email.notBlank=Email cannot be blank
validation.auth.local.currentPassword.notBlank=Current password cannot be blank
validation.auth.local.newPassword.notBlank=New password cannot be blank
validation.auth.local.email.invalid=Email format is invalid
@ -88,6 +89,7 @@ error.skill.metadata.requiredField.missing=Missing required field: {0}
error.skill.publish.publisher.notMember=Publisher is not a member of namespace: {0}
error.skill.publish.package.invalid=Package validation failed: {0}
error.skill.publish.skillMd.notFound=SKILL.md not found
error.skill.publish.precheck.confirmRequired=Pre-publish warnings require confirmation before publishing:\n{0}
error.skill.publish.precheck.failed=Pre-publish validation failed: {0}
error.skill.publish.archived=Archived skill must be restored before publishing: {0}
review.withdraw.not_pending=Only pending review submissions can be withdrawn: {0}
@ -102,7 +104,7 @@ error.skill.lifecycle.noPermission=Only the skill owner or namespace admin can m
error.skill.version.exists=Version already exists: {0}
error.skill.version.notFound=Version not found: {0}
error.skill.version.notPublished=Version is not published: {0}
error.skill.version.delete.unsupported=Only DRAFT or REJECTED versions can be deleted: {0}
error.skill.version.delete.unsupported=Only DRAFT, UPLOADED, REJECTED, or SCAN_FAILED versions can be deleted: {0}
error.skill.version.delete.lastVersion=Cannot delete the last remaining version: {0}
error.skill.report.reason.required=Please provide a report reason
error.skill.report.unavailable=This skill cannot be reported right now: {0}
@ -132,7 +134,12 @@ error.admin.user.role.superAdmin.assignDenied=Only SUPER_ADMIN can assign SUPER_
error.admin.user.status.invalid=Invalid user status: {0}
error.admin.user.status.unsupported=Only ACTIVE or DISABLED status can be managed here
error.skill.publish.nameConflict=A published skill with name ''{0}'' already exists in this namespace
error.skill.publish.nameConflict.private=A private skill with name ''{0}'' has already been published in this namespace
error.skill.approve.nameConflict=Cannot approve: a published skill with name ''{0}'' already exists in this namespace
error.skill.version.submit.notUploaded=Version ''{0}'' is not in UPLOADED status and cannot be submitted for review
error.skill.version.confirm.notUploaded=Version ''{0}'' is not in UPLOADED status and cannot be confirmed
error.skill.confirm.notPrivate=Only PRIVATE skills can use confirm-publish
error.skill.version.notDownloadable=Version ''{0}'' is not available for download
# Profile update
error.profile.displayName.length=Display name must be between 2 and 32 characters
@ -147,3 +154,16 @@ error.profileReview.commentRequired=Rejection reason is required
error.profileReview.commentTooLong=Rejection reason must not exceed 500 characters
error.profileReview.status.invalid=Invalid review status: {0}
error.profileReview.userDisabled=Cannot apply changes — user account is disabled
# Password reset
response.auth.password.reset.requested=If the account is eligible, a password reset verification code has been sent.
response.auth.password.reset.confirmed=Password has been reset successfully. Please sign in with your new password.
error.auth.password.reset.invalid.code=The verification code is invalid or has expired.
error.auth.password.reset.not.eligible=This account is not eligible for password reset.
error.auth.password.reset.no.credential=This account does not have a local credential.
error.auth.password.reset.email.failed=Failed to send password reset verification code. Please try again later.
validation.auth.password.reset.email.notBlank=Email cannot be blank
validation.auth.password.reset.email.invalid=Email format is invalid
validation.auth.password.reset.code.notBlank=Verification code cannot be blank
validation.auth.password.reset.code.invalid=Verification code must be 6 digits
validation.auth.password.reset.newPassword.notBlank=New password cannot be blank

View file

@ -16,6 +16,7 @@ validation.member.userId.notNull=用户 ID 不能为空
validation.member.role.notNull=角色不能为空
validation.auth.local.username.notBlank=用户名不能为空
validation.auth.local.password.notBlank=密码不能为空
validation.auth.local.email.notBlank=邮箱不能为空
validation.auth.local.currentPassword.notBlank=当前密码不能为空
validation.auth.local.newPassword.notBlank=新密码不能为空
validation.auth.local.email.invalid=邮箱格式不正确
@ -88,6 +89,7 @@ error.skill.metadata.requiredField.missing=缺少必填字段:{0}
error.skill.publish.publisher.notMember=发布者不是命名空间成员:{0}
error.skill.publish.package.invalid=技能包校验失败:{0}
error.skill.publish.skillMd.notFound=未找到 SKILL.md
error.skill.publish.precheck.confirmRequired=预发布发现以下风险提醒,确认后仍可继续发布:\n{0}
error.skill.publish.precheck.failed=预发布校验失败:{0}
error.skill.publish.archived=该技能已归档,请先恢复后再发布:{0}
review.withdraw.not_pending=只有待审核版本才能撤销审核:{0}
@ -102,7 +104,7 @@ error.skill.lifecycle.noPermission=只有技能所有者或命名空间管理员
error.skill.version.exists=版本已存在:{0}
error.skill.version.notFound=未找到版本:{0}
error.skill.version.notPublished=版本未发布:{0}
error.skill.version.delete.unsupported=只有 DRAFT 或 REJECTED 版本可以删除:{0}
error.skill.version.delete.unsupported=只有 DRAFT、UPLOADED、REJECTED 或 SCAN_FAILED 版本可以删除:{0}
error.skill.version.delete.lastVersion=无法删除最后一个版本:{0}
error.skill.report.reason.required=请填写举报原因
error.skill.report.unavailable=当前无法举报该技能:{0}
@ -132,7 +134,12 @@ error.admin.user.role.superAdmin.assignDenied=只有 SUPER_ADMIN 可以分配 SU
error.admin.user.status.invalid=无效的用户状态:{0}
error.admin.user.status.unsupported=这里只允许管理 ACTIVE 或 DISABLED 状态的用户
error.skill.publish.nameConflict=该命名空间下已存在名为"{0}"的已发布技能,无法提交
error.skill.publish.nameConflict.private=该命名空间下已存在名为"{0}"的已发布私有技能,无法提交
error.skill.approve.nameConflict=无法通过审核:该命名空间下已存在名为"{0}"的已发布技能
error.skill.version.submit.notUploaded=版本"{0}"不在 UPLOADED 状态,无法提交审核
error.skill.version.confirm.notUploaded=版本"{0}"不在 UPLOADED 状态,无法确认发布
error.skill.confirm.notPrivate=只有 PRIVATE 技能可以使用确认发布功能
error.skill.version.notDownloadable=版本"{0}"不可下载
# 用户资料修改
error.profile.displayName.length=昵称长度需在 2-32 个字符之间
@ -147,3 +154,16 @@ error.profileReview.commentRequired=拒绝原因不能为空
error.profileReview.commentTooLong=拒绝原因不能超过 500 个字符
error.profileReview.status.invalid=无效的审核状态:{0}
error.profileReview.userDisabled=无法应用变更——用户账号已被禁用
# Password reset
response.auth.password.reset.requested=如果账号符合条件,密码重置验证码已发送。
response.auth.password.reset.confirmed=密码已重置成功,请使用新密码登录。
error.auth.password.reset.invalid.code=验证码无效或已过期。
error.auth.password.reset.not.eligible=该账号不符合密码重置条件。
error.auth.password.reset.no.credential=该账号没有本地凭证。
error.auth.password.reset.email.failed=发送密码重置验证码失败,请稍后重试。
validation.auth.password.reset.email.notBlank=邮箱不能为空
validation.auth.password.reset.email.invalid=邮箱格式不正确
validation.auth.password.reset.code.notBlank=验证码不能为空
validation.auth.password.reset.code.invalid=验证码必须为 6 位数字
validation.auth.password.reset.newPassword.notBlank=新密码不能为空

View file

@ -0,0 +1,80 @@
package com.iflytek.skillhub.compat;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import com.iflytek.skillhub.controller.support.MultipartPackageExtractor;
import com.iflytek.skillhub.controller.support.ZipPackageExtractor;
import com.iflytek.skillhub.domain.audit.AuditLogService;
import com.iflytek.skillhub.domain.namespace.Namespace;
import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException;
import com.iflytek.skillhub.domain.skill.Skill;
import com.iflytek.skillhub.domain.skill.SkillVisibility;
import com.iflytek.skillhub.domain.skill.service.SkillPublishService;
import com.iflytek.skillhub.domain.skill.service.SkillQueryService;
import com.iflytek.skillhub.domain.social.SkillStarService;
import com.iflytek.skillhub.service.SkillSearchAppService;
import java.util.Map;
import java.util.Optional;
import org.junit.jupiter.api.Test;
class ClawHubCompatAppServiceTest {
private final SkillSearchAppService skillSearchAppService = mock(SkillSearchAppService.class);
private final SkillQueryService skillQueryService = mock(SkillQueryService.class);
private final SkillPublishService skillPublishService = mock(SkillPublishService.class);
private final ZipPackageExtractor zipPackageExtractor = mock(ZipPackageExtractor.class);
private final MultipartPackageExtractor multipartPackageExtractor = mock(MultipartPackageExtractor.class);
private final AuditLogService auditLogService = mock(AuditLogService.class);
private final CompatSkillLookupService compatSkillLookupService = mock(CompatSkillLookupService.class);
private final SkillStarService skillStarService = mock(SkillStarService.class);
private final ClawHubCompatAppService service = new ClawHubCompatAppService(
new CanonicalSlugMapper(),
skillSearchAppService,
skillQueryService,
skillPublishService,
zipPackageExtractor,
multipartPackageExtractor,
auditLogService,
compatSkillLookupService,
skillStarService
);
@Test
void downloadLocationByQuery_throwsNotFound_whenLegacySkillIsPrivateForAnonymousCaller() {
Namespace namespace = new Namespace("team-a", "Team A", "owner-1");
Skill privateSkill = new Skill(1L, "priv", "owner-1", SkillVisibility.PRIVATE);
CompatSkillLookupService.CompatSkillContext context = new CompatSkillLookupService.CompatSkillContext(
namespace,
privateSkill,
Optional.empty()
);
when(compatSkillLookupService.findByLegacySlug("priv")).thenReturn(context);
when(compatSkillLookupService.canAccess(privateSkill, null, Map.of())).thenReturn(false);
assertThatThrownBy(() -> service.downloadLocationByQuery("priv", "latest", null, null))
.isInstanceOf(DomainNotFoundException.class);
}
@Test
void downloadLocationByQuery_returnsCanonicalPath_whenLegacySkillIsVisible() {
Namespace namespace = new Namespace("team-a", "Team A", "owner-1");
Skill publicSkill = new Skill(1L, "my-skill", "owner-1", SkillVisibility.PUBLIC);
CompatSkillLookupService.CompatSkillContext context = new CompatSkillLookupService.CompatSkillContext(
namespace,
publicSkill,
Optional.empty()
);
when(compatSkillLookupService.findByLegacySlug("my-skill")).thenReturn(context);
when(compatSkillLookupService.canAccess(publicSkill, null, Map.of())).thenReturn(true);
String location = service.downloadLocationByQuery("my-skill", "latest", null, null);
assertThat(location).isEqualTo("/api/v1/skills/team-a/my-skill/download");
}
}

View file

@ -0,0 +1,102 @@
package com.iflytek.skillhub.compat;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import com.iflytek.skillhub.auth.device.DeviceAuthService;
import com.iflytek.skillhub.compat.dto.ClawHubSkillResponse;
import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository;
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException;
import java.util.Map;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("test")
class ClawHubCompatControllerSecurityTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private NamespaceMemberRepository namespaceMemberRepository;
@MockBean
private DeviceAuthService deviceAuthService;
@MockBean
private ClawHubCompatAppService clawHubCompatAppService;
@Test
void getSkill_returnsNotFound_whenAnonymousCannotAccessPrivateSkill() throws Exception {
when(clawHubCompatAppService.getSkill(eq("priv"), isNull(), isNull()))
.thenThrow(new DomainNotFoundException("error.skill.notFound", "priv"));
mockMvc.perform(get("/api/v1/skills/priv"))
.andExpect(status().isNotFound());
}
@Test
void getSkill_returnsSkill_whenCallerHasNamespacePermission() throws Exception {
var roles = Map.of(1L, NamespaceRole.ADMIN);
var response = new ClawHubSkillResponse(
new ClawHubSkillResponse.SkillInfo(
"team-ai--priv",
"Private Skill",
"summary",
Map.of(),
Map.of(),
0L,
0L
),
null,
null,
new ClawHubSkillResponse.ModerationInfo(false, false, "clean", new String[0], null, null, null)
);
when(clawHubCompatAppService.getSkill("team-ai--priv", "admin-1", roles)).thenReturn(response);
mockMvc.perform(get("/api/v1/skills/team-ai--priv")
.requestAttr("userId", "admin-1")
.requestAttr("userNsRoles", roles))
.andExpect(status().isOk())
.andExpect(jsonPath("$.skill.slug").value("team-ai--priv"));
verify(clawHubCompatAppService).getSkill("team-ai--priv", "admin-1", roles);
}
@Test
void downloadQuery_returnsNotFound_whenAnonymousCannotAccessPrivateLegacySlug() throws Exception {
when(clawHubCompatAppService.downloadLocationByQuery(eq("priv"), eq("latest"), isNull(), isNull()))
.thenThrow(new DomainNotFoundException("error.skill.notFound", "priv"));
mockMvc.perform(get("/api/v1/download")
.param("slug", "priv")
.param("version", "latest"))
.andExpect(status().isNotFound());
}
@Test
void downloadQuery_returnsNotFound_whenUserWithoutNamespaceRoleAccessesPrivateLegacySlug() throws Exception {
when(clawHubCompatAppService.downloadLocationByQuery("priv", "latest", "user-1", Map.of()))
.thenThrow(new DomainNotFoundException("error.skill.notFound", "priv"));
mockMvc.perform(get("/api/v1/download")
.param("slug", "priv")
.param("version", "latest")
.requestAttr("userId", "user-1")
.requestAttr("userNsRoles", Map.of()))
.andExpect(status().isNotFound());
}
}

View file

@ -2,35 +2,49 @@ package com.iflytek.skillhub.compat;
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
import com.iflytek.skillhub.auth.device.DeviceAuthService;
import com.iflytek.skillhub.domain.audit.AuditLogService;
import com.iflytek.skillhub.domain.namespace.Namespace;
import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository;
import com.iflytek.skillhub.domain.skill.SkillVersion;
import com.iflytek.skillhub.domain.skill.SkillVersionStatus;
import com.iflytek.skillhub.domain.skill.service.SkillQueryService;
import com.iflytek.skillhub.domain.skill.service.SkillPublishService;
import com.iflytek.skillhub.domain.skill.Skill;
import com.iflytek.skillhub.domain.skill.SkillVisibility;
import com.iflytek.skillhub.dto.SkillLifecycleVersionResponse;
import com.iflytek.skillhub.dto.SkillSummaryResponse;
import com.iflytek.skillhub.service.SkillSearchAppService;
import java.math.BigDecimal;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.test.web.servlet.MockMvc;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import static org.mockito.ArgumentMatchers.anyList;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.BDDMockito.given;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyMap;
import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@SpringBootTest
@ -56,6 +70,12 @@ class ClawHubCompatControllerTest {
@MockBean
private CompatSkillLookupService compatSkillLookupService;
@MockBean
private SkillPublishService skillPublishService;
@MockBean
private AuditLogService auditLogService;
@Test
void search_returns_mapped_results() throws Exception {
when(skillSearchAppService.search("test", null, "relevance", 0, 20, null, null))
@ -133,6 +153,7 @@ class ClawHubCompatControllerTest {
void resolve_query_with_legacy_slug_keeps_legacy_lookup_behavior() throws Exception {
when(compatSkillLookupService.findByLegacySlug("my-skill"))
.thenReturn(legacyCompatContext("global", "my-skill"));
when(compatSkillLookupService.canAccess(any(), isNull(), anyMap())).thenReturn(true);
when(skillQueryService.resolveVersion("global", "my-skill", null, "latest", null, null, java.util.Map.of()))
.thenReturn(new SkillQueryService.ResolvedVersionDTO(
1L, "global", "my-skill", "latest", 2L, "sha", true, "/api/v1/skills/global/my-skill/download"));
@ -144,6 +165,7 @@ class ClawHubCompatControllerTest {
.andExpect(jsonPath("$.match.version").value("latest"))
.andExpect(jsonPath("$.latestVersion.version").value("latest"));
verify(compatSkillLookupService).canAccess(any(), isNull(), anyMap());
verify(skillQueryService).resolveVersion("global", "my-skill", null, "latest", null, null, java.util.Map.of());
}
@ -160,11 +182,14 @@ class ClawHubCompatControllerTest {
void download_query_with_legacy_slug_keeps_legacy_lookup_behavior() throws Exception {
when(compatSkillLookupService.findByLegacySlug("my-skill"))
.thenReturn(legacyCompatContext("global", "my-skill"));
when(compatSkillLookupService.canAccess(any(), isNull(), anyMap())).thenReturn(true);
mockMvc.perform(get("/api/v1/download")
.param("slug", "my-skill")
.param("version", "latest"))
.andExpect(status().isFound())
.andExpect(header().string("Location", "/api/v1/skills/global/my-skill/download"));
verify(compatSkillLookupService).canAccess(any(), isNull(), anyMap());
}
@Test
@ -204,9 +229,122 @@ class ClawHubCompatControllerTest {
.andExpect(jsonPath("$.user.image").value("https://example.com/avatar.png"));
}
@Test
void publish_skill_with_canonical_slug_routes_to_namespace_publish() throws Exception {
SkillVersion version = publishVersion("1.0.0", 34L);
given(skillPublishService.publishFromEntries(
eq("team-ai"),
anyList(),
eq("user-42"),
eq(SkillVisibility.PUBLIC),
eq(Set.of("SUPER_ADMIN")),
eq(false)))
.willReturn(new SkillPublishService.PublishResult(12L, "my-skill", version));
mockMvc.perform(multipart("/api/v1/skills")
.file(skillMdFile())
.param("payload", """
{"slug":"team-ai--my-skill","displayName":"My Skill","version":"1.0.0","acceptLicenseTerms":true,"tags":["latest"]}
""")
.with(authentication(superAdminAuth()))
.with(csrf()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.ok").value(true))
.andExpect(jsonPath("$.skillId").value("12"))
.andExpect(jsonPath("$.versionId").value("34"));
}
@Test
void publish_skill_with_plain_slug_defaults_to_global_namespace() throws Exception {
SkillVersion version = publishVersion("1.0.0", 35L);
given(skillPublishService.publishFromEntries(
eq("global"),
anyList(),
eq("user-42"),
eq(SkillVisibility.PUBLIC),
eq(Set.of("SUPER_ADMIN")),
eq(false)))
.willReturn(new SkillPublishService.PublishResult(13L, "my-skill", version));
mockMvc.perform(multipart("/api/v1/skills")
.file(skillMdFile())
.param("payload", """
{"slug":"my-skill","displayName":"My Skill","version":"1.0.0","acceptLicenseTerms":true,"tags":["latest"]}
""")
.with(authentication(superAdminAuth()))
.with(csrf()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.ok").value(true))
.andExpect(jsonPath("$.skillId").value("13"))
.andExpect(jsonPath("$.versionId").value("35"));
}
@Test
void publish_skill_with_payload_namespace_uses_explicit_namespace() throws Exception {
SkillVersion version = publishVersion("1.0.0", 36L);
given(skillPublishService.publishFromEntries(
eq("team-explicit"),
anyList(),
eq("user-42"),
eq(SkillVisibility.PUBLIC),
eq(Set.of("SUPER_ADMIN")),
eq(false)))
.willReturn(new SkillPublishService.PublishResult(14L, "my-skill", version));
mockMvc.perform(multipart("/api/v1/skills")
.file(skillMdFile())
.param("payload", """
{"namespace":"@team-explicit","slug":"my-skill","displayName":"My Skill","version":"1.0.0","acceptLicenseTerms":true,"tags":["latest"]}
""")
.with(authentication(superAdminAuth()))
.with(csrf()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.ok").value(true))
.andExpect(jsonPath("$.skillId").value("14"))
.andExpect(jsonPath("$.versionId").value("36"));
}
private CompatSkillLookupService.CompatSkillContext legacyCompatContext(String namespaceSlug, String skillSlug) {
Namespace namespace = new Namespace(namespaceSlug, namespaceSlug, "tester");
Skill skill = new Skill(1L, skillSlug, "tester", SkillVisibility.PUBLIC);
return new CompatSkillLookupService.CompatSkillContext(namespace, skill, Optional.empty());
}
private MockMultipartFile skillMdFile() {
return new MockMultipartFile(
"files",
"SKILL.md",
"text/markdown",
"""
---
name: my-skill
description: Demo skill
version: 1.0.0
---
""".getBytes(StandardCharsets.UTF_8)
);
}
private SkillVersion publishVersion(String versionValue, long versionId) {
SkillVersion version = new SkillVersion(12L, versionValue, "user-42");
version.setStatus(SkillVersionStatus.PENDING_REVIEW);
ReflectionTestUtils.setField(version, "id", versionId);
return version;
}
private UsernamePasswordAuthenticationToken superAdminAuth() {
PlatformPrincipal principal = new PlatformPrincipal(
"user-42",
"tester",
"tester@example.com",
"https://example.com/avatar.png",
"github",
Set.of("SUPER_ADMIN")
);
return new UsernamePasswordAuthenticationToken(
principal,
null,
List.of(new SimpleGrantedAuthority("ROLE_SUPER_ADMIN"))
);
}
}

View file

@ -0,0 +1,78 @@
package com.iflytek.skillhub.compat;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import com.iflytek.skillhub.domain.namespace.Namespace;
import com.iflytek.skillhub.domain.namespace.NamespaceRepository;
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException;
import com.iflytek.skillhub.domain.skill.Skill;
import com.iflytek.skillhub.domain.skill.SkillRepository;
import com.iflytek.skillhub.domain.skill.SkillVersionRepository;
import com.iflytek.skillhub.domain.skill.SkillVisibility;
import com.iflytek.skillhub.domain.skill.VisibilityChecker;
import com.iflytek.skillhub.domain.skill.service.SkillSlugResolutionService;
import java.util.Map;
import java.util.Optional;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
class CompatSkillLookupServiceTest {
private final SkillRepository skillRepository = mock(SkillRepository.class);
private final NamespaceRepository namespaceRepository = mock(NamespaceRepository.class);
private final SkillVersionRepository skillVersionRepository = mock(SkillVersionRepository.class);
private final SkillSlugResolutionService skillSlugResolutionService = mock(SkillSlugResolutionService.class);
private final VisibilityChecker visibilityChecker = mock(VisibilityChecker.class);
private final CompatSkillLookupService service = new CompatSkillLookupService(
skillRepository,
namespaceRepository,
skillVersionRepository,
skillSlugResolutionService,
visibilityChecker
);
@Test
void resolveVisible_throwsNotFoundWhenCallerCannotAccessSkill() {
Namespace namespace = new Namespace("team-a", "Team A", "owner-1");
ReflectionTestUtils.setField(namespace, "id", 1L);
Skill privateSkill = new Skill(1L, "priv", "owner-1", SkillVisibility.PRIVATE);
ReflectionTestUtils.setField(privateSkill, "id", 7L);
privateSkill.setLatestVersionId(70L);
when(namespaceRepository.findBySlug("team-a")).thenReturn(Optional.of(namespace));
when(skillSlugResolutionService.resolve(1L, "priv", null, SkillSlugResolutionService.Preference.PUBLISHED))
.thenReturn(privateSkill);
when(visibilityChecker.canAccess(privateSkill, null, Map.of())).thenReturn(false);
assertThatThrownBy(() -> service.resolveVisible("team-a", "priv", null, Map.of()))
.isInstanceOf(DomainNotFoundException.class);
}
@Test
void resolveVisible_returnsSkillWhenCallerHasNamespaceAccess() {
Namespace namespace = new Namespace("team-a", "Team A", "owner-1");
ReflectionTestUtils.setField(namespace, "id", 1L);
Skill privateSkill = new Skill(1L, "priv", "owner-1", SkillVisibility.PRIVATE);
ReflectionTestUtils.setField(privateSkill, "id", 7L);
privateSkill.setLatestVersionId(70L);
when(namespaceRepository.findBySlug("team-a")).thenReturn(Optional.of(namespace));
when(skillSlugResolutionService.resolve(1L, "priv", "admin-1", SkillSlugResolutionService.Preference.PUBLISHED))
.thenReturn(privateSkill);
when(visibilityChecker.canAccess(privateSkill, "admin-1", Map.of(1L, NamespaceRole.ADMIN))).thenReturn(true);
CompatSkillLookupService.CompatSkillContext result = service.resolveVisible(
"team-a",
"priv",
"admin-1",
Map.of(1L, NamespaceRole.ADMIN)
);
assertThat(result.skill().getId()).isEqualTo(7L);
}
}

View file

@ -12,6 +12,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
import com.iflytek.skillhub.auth.exception.AuthFlowException;
import com.iflytek.skillhub.auth.local.LocalAuthService;
import com.iflytek.skillhub.auth.local.PasswordResetService;
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository;
import com.iflytek.skillhub.metrics.SkillHubMetrics;
@ -50,6 +51,9 @@ class LocalAuthControllerTest {
@MockBean
private AuthFailureThrottleService authFailureThrottleService;
@MockBean
private PasswordResetService passwordResetService;
@Test
void login_returnsCurrentUserEnvelope() throws Exception {
PlatformPrincipal principal = new PlatformPrincipal(
@ -119,6 +123,23 @@ class LocalAuthControllerTest {
verify(localAuthService).register("bob", "Abcd123!", "not-an-email");
}
@Test
void register_rejectsBlankEmail() throws Exception {
given(localAuthService.register("bob", "Abcd123!", " "))
.willThrow(new AuthFlowException(HttpStatus.BAD_REQUEST, "validation.auth.local.email.notBlank"));
mockMvc.perform(post("/api/v1/auth/local/register")
.with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"username":"bob","password":"Abcd123!","email":" "}
"""))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value(400));
verify(localAuthService).register("bob", "Abcd123!", " ");
}
@Test
void login_failure_recordsFailureMetric() throws Exception {
given(localAuthService.login("alice", "wrong"))
@ -176,4 +197,66 @@ class LocalAuthControllerTest {
.andExpect(jsonPath("$.code").value(0));
}
@Test
void requestPasswordReset_returnsGenericSuccessEnvelope() throws Exception {
mockMvc.perform(post("/api/v1/auth/local/password-reset/request")
.with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"email":"alice@example.com"}
"""))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0));
verify(passwordResetService).requestPasswordReset("alice@example.com");
}
@Test
void requestPasswordReset_rejectsInvalidEmailFormat() throws Exception {
willThrow(new AuthFlowException(HttpStatus.BAD_REQUEST, "validation.auth.password.reset.email.invalid"))
.given(passwordResetService).requestPasswordReset("alice");
mockMvc.perform(post("/api/v1/auth/local/password-reset/request")
.with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"email":"alice"}
"""))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value(400));
verify(passwordResetService).requestPasswordReset("alice");
}
@Test
void confirmPasswordReset_returnsUpdatedEnvelope() throws Exception {
mockMvc.perform(post("/api/v1/auth/local/password-reset/confirm")
.with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"email":"alice@example.com","code":"123456","newPassword":"Abcd123!"}
"""))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0));
verify(passwordResetService).confirmPasswordReset("alice@example.com", "123456", "Abcd123!");
}
@Test
void confirmPasswordReset_rejectsInvalidEmailFormat() throws Exception {
willThrow(new AuthFlowException(HttpStatus.BAD_REQUEST, "validation.auth.password.reset.email.invalid"))
.given(passwordResetService).confirmPasswordReset("alice", "123456", "Abcd123!");
mockMvc.perform(post("/api/v1/auth/local/password-reset/confirm")
.with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"email":"alice","code":"123456","newPassword":"Abcd123!"}
"""))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value(400));
verify(passwordResetService).confirmPasswordReset("alice", "123456", "Abcd123!");
}
}

View file

@ -94,18 +94,9 @@ class NamespacePortalControllerTest {
}
@Test
void getNamespace_hidesArchivedNamespaceFromAnonymousUsers() throws Exception {
Namespace namespace = namespace(1L, "team-a", NamespaceStatus.ARCHIVED, NamespaceType.TEAM);
given(namespaceService.getNamespaceBySlugForRead("team-a", null, Map.of())).willThrow(
new com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException(
"error.namespace.slug.notFound",
"team-a"
)
);
void getNamespace_requiresAuthentication() throws Exception {
mockMvc.perform(get("/api/v1/namespaces/team-a"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value(400));
.andExpect(status().isUnauthorized());
}
@Test

View file

@ -3,18 +3,19 @@ package com.iflytek.skillhub.controller;
import com.iflytek.skillhub.auth.device.DeviceAuthService;
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
import com.iflytek.skillhub.domain.namespace.Namespace;
import com.iflytek.skillhub.domain.namespace.NamespaceGovernanceService;
import com.iflytek.skillhub.domain.namespace.NamespaceMember;
import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository;
import com.iflytek.skillhub.domain.namespace.NamespaceMemberService;
import com.iflytek.skillhub.domain.namespace.NamespaceRepository;
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
import com.iflytek.skillhub.domain.namespace.NamespaceService;
import com.iflytek.skillhub.domain.namespace.NamespaceStatus;
import com.iflytek.skillhub.domain.namespace.NamespaceType;
import com.iflytek.skillhub.domain.user.UserAccount;
import com.iflytek.skillhub.domain.user.UserAccountRepository;
import com.iflytek.skillhub.dto.MemberResponse;
import com.iflytek.skillhub.dto.NamespaceCandidateUserResponse;
import com.iflytek.skillhub.dto.NamespaceResponse;
import com.iflytek.skillhub.dto.PageResponse;
import com.iflytek.skillhub.service.GovernanceWorkflowAppService;
import com.iflytek.skillhub.service.NamespacePortalCommandAppService;
import com.iflytek.skillhub.service.NamespacePortalQueryAppService;
import com.iflytek.skillhub.service.NamespaceMemberCandidateService;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
@ -28,7 +29,6 @@ import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.request.RequestPostProcessor;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import static org.mockito.ArgumentMatchers.any;
@ -52,25 +52,19 @@ class NamespaceWorkflowContractTest {
private MockMvc mockMvc;
@MockBean
private NamespaceService namespaceService;
private NamespacePortalCommandAppService namespacePortalCommandAppService;
@MockBean
private NamespaceGovernanceService namespaceGovernanceService;
private NamespacePortalQueryAppService namespacePortalQueryAppService;
@MockBean
private NamespaceMemberService namespaceMemberService;
@MockBean
private NamespaceRepository namespaceRepository;
@MockBean
private NamespaceMemberRepository namespaceMemberRepository;
private GovernanceWorkflowAppService governanceWorkflowAppService;
@MockBean
private NamespaceMemberCandidateService namespaceMemberCandidateService;
@MockBean
private UserAccountRepository userAccountRepository;
private NamespaceMemberRepository namespaceMemberRepository;
@MockBean
private DeviceAuthService deviceAuthService;
@ -81,25 +75,32 @@ class NamespaceWorkflowContractTest {
Namespace frozen = namespace(7L, "team-flow", NamespaceStatus.FROZEN, NamespaceType.TEAM);
Namespace archived = namespace(7L, "team-flow", NamespaceStatus.ARCHIVED, NamespaceType.TEAM);
NamespaceMember adminMember = new NamespaceMember(7L, "user-admin", NamespaceRole.ADMIN);
UserAccount adminUser = new UserAccount("user-admin", "Admin", "admin@example.com", null);
setMemberId(adminMember, 11L);
NamespaceResponse namespaceResponse = NamespaceResponse.from(namespace);
NamespaceResponse frozenResponse = NamespaceResponse.from(frozen);
NamespaceResponse archivedResponse = NamespaceResponse.from(archived);
MemberResponse adminMemberResponse = MemberResponse.from(
adminMember,
new UserAccount("user-admin", "Admin", "admin@example.com", null)
);
given(namespaceService.createNamespace(eq("team-flow"), eq("Team Flow"), eq("workflow"), eq("owner-1")))
.willReturn(namespace);
given(namespaceService.getNamespaceBySlug("team-flow")).willReturn(namespace);
given(namespaceGovernanceService.freezeNamespace(eq("team-flow"), eq("owner-1"), eq(null), eq(null), any(), any()))
.willReturn(frozen);
given(namespaceGovernanceService.archiveNamespace(eq("team-flow"), eq("owner-1"), eq("cleanup"), eq(null), any(), any()))
.willReturn(archived);
given(namespacePortalCommandAppService.createNamespace(any(), any()))
.willReturn(namespaceResponse);
given(governanceWorkflowAppService.freezeNamespace(eq("team-flow"), any(), eq("owner-1"), any()))
.willReturn(frozenResponse);
given(governanceWorkflowAppService.archiveNamespace(eq("team-flow"), any(), eq("owner-1"), any()))
.willReturn(archivedResponse);
given(namespaceMemberCandidateService.searchCandidates("team-flow", "admin", "owner-1", 10))
.willReturn(List.of(new NamespaceCandidateUserResponse("user-admin", "Admin", "admin@example.com", "ACTIVE")));
given(namespaceMemberService.addMember(7L, "user-admin", NamespaceRole.ADMIN, "owner-1"))
.willReturn(adminMember);
given(namespaceMemberService.listMembers(eq(7L), any(org.springframework.data.domain.Pageable.class)))
.willReturn(new org.springframework.data.domain.PageImpl<>(List.of(adminMember)));
given(namespaceMemberService.updateMemberRole(7L, "user-admin", NamespaceRole.ADMIN, "owner-1"))
.willReturn(adminMember);
given(userAccountRepository.findById("user-admin"))
.willReturn(Optional.of(new UserAccount("user-admin", "Admin", "admin@example.com", null)));
given(namespacePortalCommandAppService.addMember("team-flow", "user-admin", NamespaceRole.ADMIN, "owner-1"))
.willReturn(adminMemberResponse);
given(namespacePortalQueryAppService.listMembers(eq("team-flow"), any(org.springframework.data.domain.Pageable.class), eq("owner-1")))
.willReturn(new PageResponse<>(List.of(adminMemberResponse), 1, 0, 20));
given(namespacePortalCommandAppService.updateMemberRole(eq("team-flow"), eq("user-admin"), any(), eq("owner-1")))
.willReturn(adminMemberResponse);
given(namespacePortalCommandAppService.removeMember("team-flow", "user-admin", "owner-1"))
.willReturn(new com.iflytek.skillhub.dto.MessageResponse("Member removed successfully"));
mockMvc.perform(post("/api/web/namespaces")
.with(csrf())
@ -127,14 +128,18 @@ class NamespaceWorkflowContractTest {
.content("{\"userId\":\"user-admin\",\"role\":\"ADMIN\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.userId").value("user-admin"));
.andExpect(jsonPath("$.data.userId").value("user-admin"))
.andExpect(jsonPath("$.data.displayName").value("Admin"))
.andExpect(jsonPath("$.data.email").value("admin@example.com"));
mockMvc.perform(get("/api/web/namespaces/team-flow/members")
.with(auth("owner-1"))
.requestAttr("userId", "owner-1"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.items[0].userId").value("user-admin"));
.andExpect(jsonPath("$.data.items[0].userId").value("user-admin"))
.andExpect(jsonPath("$.data.items[0].displayName").value("Admin"))
.andExpect(jsonPath("$.data.items[0].email").value("admin@example.com"));
mockMvc.perform(put("/api/web/namespaces/team-flow/members/user-admin/role")
.with(csrf())

View file

@ -22,6 +22,7 @@ import java.util.Map;
import java.util.TimeZone;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.anySet;
import static org.mockito.Mockito.when;
import static org.mockito.ArgumentMatchers.any;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;

View file

@ -1,6 +1,7 @@
package com.iflytek.skillhub.controller.admin;
import com.iflytek.skillhub.TestRedisConfig;
import com.iflytek.skillhub.auth.local.PasswordResetService;
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
import com.iflytek.skillhub.auth.device.DeviceAuthService;
import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository;
@ -52,6 +53,9 @@ class UserManagementControllerTest {
@MockBean
private AdminUserAppService adminUserAppService;
@MockBean
private PasswordResetService passwordResetService;
@Test
void listUsers_unauthenticated_returns401() throws Exception {
mockMvc.perform(get("/api/v1/admin/users"))
@ -243,4 +247,22 @@ class UserManagementControllerTest {
verify(adminUserAppService).updateUserStatus("user-123", "ACTIVE");
}
@Test
void triggerPasswordReset_withUserAdminRole_returns200() throws Exception {
PlatformPrincipal principal = new PlatformPrincipal(
"user-42", "admin", "admin@example.com", "", "github", Set.of("USER_ADMIN")
);
var auth = new UsernamePasswordAuthenticationToken(
principal, null, List.of(new SimpleGrantedAuthority("ROLE_USER_ADMIN"))
);
mockMvc.perform(post("/api/v1/admin/users/user-123/password-reset")
.with(authentication(auth))
.with(csrf()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0));
verify(passwordResetService).adminTriggerPasswordReset("user-123", "user-42");
}
}

View file

@ -1,6 +1,8 @@
package com.iflytek.skillhub.controller.portal;
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
import com.iflytek.skillhub.domain.namespace.NamespaceMember;
import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository;
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
import com.iflytek.skillhub.domain.security.ScannerType;
import com.iflytek.skillhub.domain.security.SecurityAudit;
@ -56,6 +58,9 @@ class SecurityAuditControllerTest {
@MockBean
private ScanTaskProducer scanTaskProducer;
@MockBean
private NamespaceMemberRepository namespaceMemberRepository;
@Test
void getSecurityAudit_returnsAuditPayload() throws Exception {
SecurityAudit audit = new SecurityAudit(42L, ScannerType.SKILL_SCANNER);
@ -129,6 +134,29 @@ class SecurityAuditControllerTest {
.andExpect(jsonPath("$.code").value(403));
}
@Test
void getSecurityAudit_allowsNamespaceAdminForPendingUnpublishedSkill() throws Exception {
SecurityAudit audit = new SecurityAudit(42L, ScannerType.SKILL_SCANNER);
setField(audit, "id", 9L);
audit.setScanId("scan-team-admin");
audit.setVerdict(SecurityVerdict.SAFE);
audit.setIsSafe(true);
audit.setMaxSeverity("LOW");
audit.setFindingsCount(0);
given(skillVersionRepository.findById(42L)).willReturn(java.util.Optional.of(skillVersion(42L, 8L)));
given(skillRepository.findById(8L)).willReturn(java.util.Optional.of(skill(8L, "owner-1")));
given(securityAuditRepository.findLatestActiveByVersionId(42L)).willReturn(List.of(audit));
given(namespaceMemberRepository.findByUserId("team-admin"))
.willReturn(List.of(new NamespaceMember(5L, "team-admin", NamespaceRole.ADMIN)));
mockMvc.perform(get("/api/v1/skills/8/versions/42/security-audit")
.with(auth("team-admin")))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data[0].id").value(9L))
.andExpect(jsonPath("$.data[0].scanId").value("scan-team-admin"));
}
private RequestPostProcessor auth(String userId) {
PlatformPrincipal principal = new PlatformPrincipal(
userId,

View file

@ -7,6 +7,7 @@ import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
import com.iflytek.skillhub.auth.rbac.RbacService;
import com.iflytek.skillhub.domain.namespace.Namespace;
import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository;
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
import com.iflytek.skillhub.domain.namespace.NamespaceRepository;
import com.iflytek.skillhub.domain.namespace.NamespaceType;
import com.iflytek.skillhub.domain.review.ReviewTask;
@ -128,6 +129,37 @@ class SkillApprovalVisibilityFlowIntegrationTest {
assertThat(indexedDocument.getTitle()).isEqualTo(graph.skill().getDisplayName());
}
@Test
void namespaceAdminCanApproveOwnTeamReview() throws Exception {
PendingSkillGraph graph = createPendingTeamSkill("team-admin");
when(namespaceMemberRepository.findByUserId("team-admin"))
.thenReturn(List.of(new com.iflytek.skillhub.domain.namespace.NamespaceMember(
graph.namespace().getId(),
"team-admin",
NamespaceRole.ADMIN
)));
when(rbacService.getUserRoleCodes("team-admin")).thenReturn(Set.of());
mockMvc.perform(post("/api/v1/reviews/" + graph.reviewTask().getId() + "/approve")
.contentType("application/json")
.content("{\"comment\":\"approved by namespace admin\"}")
.with(authentication(apiAuth("team-admin")))
.with(csrf()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.id").value(graph.reviewTask().getId()))
.andExpect(jsonPath("$.data.status").value("APPROVED"))
.andExpect(jsonPath("$.data.reviewedBy").value("team-admin"))
.andExpect(jsonPath("$.data.reviewComment").value("approved by namespace admin"));
Skill savedSkill = skillRepository.findById(graph.skill().getId()).orElseThrow();
SkillVersion savedVersion = skillVersionRepository.findById(graph.version().getId()).orElseThrow();
assertThat(savedSkill.getLatestVersionId()).isEqualTo(graph.version().getId());
assertThat(savedVersion.getStatus()).isEqualTo(SkillVersionStatus.PUBLISHED);
assertThat(savedVersion.getPublishedAt()).isNotNull();
}
private PendingSkillGraph createPendingGlobalSkill(String ownerId) {
String suffix = UUID.randomUUID().toString().substring(0, 8);
@ -154,8 +186,33 @@ class SkillApprovalVisibilityFlowIntegrationTest {
return new PendingSkillGraph(namespace, skill, version, reviewTask);
}
private PendingSkillGraph createPendingTeamSkill(String ownerId) {
String suffix = UUID.randomUUID().toString().substring(0, 8);
Namespace namespace = new Namespace("team-approval-" + suffix, "Team Approval " + suffix, ownerId);
namespace = namespaceRepository.save(namespace);
Skill skill = new Skill(namespace.getId(), "approval-skill-" + suffix, ownerId, SkillVisibility.PUBLIC);
skill.setDisplayName("Approval Skill " + suffix);
skill.setSummary("Team namespace self-review should be allowed for namespace admins.");
skill.setCreatedBy(ownerId);
skill.setUpdatedBy(ownerId);
skill = skillRepository.save(skill);
skillRepository.flush();
SkillVersion version = new SkillVersion(skill.getId(), "1.0.0", ownerId);
version.setStatus(SkillVersionStatus.PENDING_REVIEW);
version.setRequestedVisibility(SkillVisibility.PUBLIC);
version = skillVersionRepository.save(version);
skillVersionRepository.flush();
ReviewTask reviewTask = reviewTaskJpaRepository.saveAndFlush(new ReviewTask(version.getId(), namespace.getId(), ownerId));
return new PendingSkillGraph(namespace, skill, version, reviewTask);
}
private SkillSearchDocumentEntity awaitIndexedDocument(Long skillId) throws InterruptedException {
Instant deadline = Instant.now().plus(Duration.ofSeconds(5));
Instant deadline = Instant.now().plus(Duration.ofSeconds(15));
Optional<SkillSearchDocumentEntity> indexed = skillSearchDocumentJpaRepository.findBySkillId(skillId);
while (indexed.isEmpty() && Instant.now().isBefore(deadline)) {
Thread.sleep(100L);

View file

@ -212,7 +212,8 @@ class SkillLifecycleControllerTest {
eq("1.2.3"),
eq("1.2.4"),
eq("usr_1"),
anyMap()))
anyMap(),
eq(false)))
.willReturn(new SkillPublishService.PublishResult(1L, "demo-skill", newVersion));
mockMvc.perform(post("/api/web/skills/global/demo-skill/versions/1.2.3/rerelease")
@ -279,7 +280,8 @@ class SkillLifecycleControllerTest {
eq("1.2.3"),
eq("1.2.4"),
eq("usr_1"),
anyMap()))
anyMap(),
eq(false)))
.willReturn(new SkillPublishService.PublishResult(1L, "demo-skill", newVersion));
mockMvc.perform(post("/api/web/skills/global/demo-skill/versions/1.2.3/rerelease")
@ -299,7 +301,44 @@ class SkillLifecycleControllerTest {
eq("1.2.3"),
eq("1.2.4"),
eq("usr_1"),
anyMap());
anyMap(),
eq(false));
}
@Test
void rereleaseVersion_passesConfirmWarningsToService() throws Exception {
Namespace namespace = new Namespace("global", "Global", "owner");
setNamespaceId(namespace, 1L);
Skill skill = new Skill(1L, "demo-skill", "owner", SkillVisibility.PUBLIC);
setSkillId(skill, 1L);
SkillVersion newVersion = new SkillVersion(1L, "1.2.4", "owner");
setSkillVersionId(newVersion, 3L);
newVersion.setStatus(SkillVersionStatus.PUBLISHED);
given(namespaceRepository.findBySlug("global")).willReturn(java.util.Optional.of(namespace));
given(skillSlugResolutionService.resolve(1L, "demo-skill", "usr_1", SkillSlugResolutionService.Preference.CURRENT_USER))
.willReturn(skill);
SkillVersion sourceVersion = new SkillVersion(1L, "1.2.3", "owner");
setSkillVersionId(sourceVersion, 2L);
sourceVersion.setStatus(SkillVersionStatus.PUBLISHED);
given(skillVersionRepository.findBySkillIdAndVersion(1L, "1.2.3")).willReturn(java.util.Optional.of(sourceVersion));
given(skillPublishService.rereleasePublishedVersion(
eq(1L), eq("1.2.3"), eq("1.2.4"), eq("usr_1"), anyMap(), eq(true)))
.willReturn(new SkillPublishService.PublishResult(1L, "demo-skill", newVersion));
mockMvc.perform(post("/api/web/skills/global/demo-skill/versions/1.2.3/rerelease")
.requestAttr("userId", "usr_1")
.requestAttr("userNsRoles", java.util.Map.of(1L, NamespaceRole.ADMIN))
.contentType(MediaType.APPLICATION_JSON)
.content("{\"targetVersion\":\"1.2.4\",\"confirmWarnings\":true}")
.with(user("usr_1"))
.with(csrf()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.action").value("RERELEASE_VERSION"));
verify(skillPublishService).rereleasePublishedVersion(
eq(1L), eq("1.2.3"), eq("1.2.4"), eq("usr_1"), anyMap(), eq(true));
}
private Skill skillWithStatus(Skill skill, com.iflytek.skillhub.domain.skill.SkillStatus status) {

View file

@ -4,6 +4,9 @@ import static org.mockito.ArgumentMatchers.anyList;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.verify;
import com.iflytek.skillhub.domain.skill.validation.PackageEntry;
import org.mockito.ArgumentMatchers;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart;
@ -69,10 +72,11 @@ class SkillPublishControllerTest {
given(skillPublishService.publishFromEntries(
eq("global"),
anyList(),
ArgumentMatchers.<List<PackageEntry>>any(),
eq("usr_1"),
eq(SkillVisibility.PUBLIC),
eq(Set.of("SUPER_ADMIN"))))
eq(Set.of("SUPER_ADMIN")),
eq(false)))
.willReturn(new SkillPublishService.PublishResult(12L, "demo-skill", version));
PlatformPrincipal principal = new PlatformPrincipal(
@ -109,6 +113,54 @@ class SkillPublishControllerTest {
verify(skillHubMetrics).incrementSkillPublish("global", "PENDING_REVIEW");
}
@Test
void publish_passesWarningConfirmationFlag() throws Exception {
SkillVersion version = new SkillVersion(12L, "1.0.0", "usr_1");
version.setStatus(SkillVersionStatus.PENDING_REVIEW);
version.setFileCount(1);
version.setTotalSize(128L);
ReflectionTestUtils.setField(version, "id", 34L);
given(skillPublishService.publishFromEntries(
eq("global"),
ArgumentMatchers.<List<PackageEntry>>any(),
eq("usr_1"),
eq(SkillVisibility.PUBLIC),
eq(Set.of("SUPER_ADMIN")),
eq(true)))
.willReturn(new SkillPublishService.PublishResult(12L, "demo-skill", version));
PlatformPrincipal principal = new PlatformPrincipal(
"usr_1",
"publisher",
"publisher@example.com",
"",
"local",
Set.of("SUPER_ADMIN")
);
var auth = new UsernamePasswordAuthenticationToken(
principal,
null,
List.of(new SimpleGrantedAuthority("ROLE_SUPER_ADMIN"))
);
MockMultipartFile file = new MockMultipartFile(
"file",
"skill.zip",
"application/zip",
buildZipBytes()
);
mockMvc.perform(multipart("/api/v1/skills/global/publish")
.file(file)
.param("visibility", "PUBLIC")
.param("confirmWarnings", "true")
.with(authentication(auth))
.with(csrf()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0));
}
private byte[] buildZipBytes() throws Exception {
try (ByteArrayOutputStream output = new ByteArrayOutputStream();
ZipOutputStream zip = new ZipOutputStream(output, StandardCharsets.UTF_8)) {

View file

@ -35,13 +35,14 @@ class PrometheusEndpointTest {
private DeviceAuthService deviceAuthService;
@Test
void prometheusEndpoint_exposesCustomMetrics() {
void metricsRegistry_stillRecordsCustomMetrics_whenPrometheusEndpointIsDisabled() {
skillHubMetrics.incrementUserRegister();
skillHubMetrics.recordLocalLogin(true);
skillHubMetrics.incrementSkillPublish("global", "PENDING_REVIEW");
assertThat(environment.getProperty("management.endpoints.web.exposure.include"))
.contains("prometheus");
.doesNotContain("prometheus")
.doesNotContain("metrics");
assertThat(meterRegistry.get("skillhub.user.register").counter().count()).isEqualTo(1.0d);
assertThat(meterRegistry.get("skillhub.auth.login")
.tag("method", "local")

View file

@ -1,6 +1,7 @@
package com.iflytek.skillhub.service;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyList;
import static org.mockito.ArgumentMatchers.eq;
@ -16,6 +17,7 @@ import com.iflytek.skillhub.domain.namespace.NamespaceRole;
import com.iflytek.skillhub.domain.namespace.NamespaceService;
import com.iflytek.skillhub.domain.namespace.NamespaceStatus;
import com.iflytek.skillhub.domain.namespace.NamespaceType;
import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException;
import com.iflytek.skillhub.domain.user.UserAccount;
import com.iflytek.skillhub.domain.user.UserAccountRepository;
import com.iflytek.skillhub.dto.MemberResponse;
@ -71,6 +73,39 @@ class NamespacePortalQueryAppServiceTest {
assertThat(response.get(1).currentUserRole()).isEqualTo(NamespaceRole.ADMIN);
}
@Test
void listNamespaces_returnsOnlyCurrentUsersActiveNamespaces() {
Namespace teamA = namespace(1L, "team-a");
Namespace teamB = namespace(2L, "team-b");
Namespace archived = namespace(3L, "archived");
archived.setStatus(NamespaceStatus.ARCHIVED);
when(namespaceRepository.findByIdIn(anyList())).thenReturn(List.of(teamB, archived, teamA));
var response = service.listNamespaces(
PageRequest.of(0, 10),
Map.of(
1L, NamespaceRole.MEMBER,
2L, NamespaceRole.ADMIN,
3L, NamespaceRole.OWNER
)
);
assertThat(response.items()).hasSize(2);
assertThat(response.items().get(0).slug()).isEqualTo("team-a");
assertThat(response.items().get(1).slug()).isEqualTo("team-b");
}
@Test
void getNamespace_throwsWhenCurrentUserIsNotNamespaceMember() {
Namespace namespace = namespace(1L, "team-a");
when(namespaceService.getNamespaceBySlugForRead("team-a", "user-1", Map.of()))
.thenReturn(namespace);
assertThatThrownBy(() -> service.getNamespace("team-a", "user-1", Map.of()))
.isInstanceOf(DomainForbiddenException.class);
}
private Namespace namespace(Long id, String slug) {
Namespace namespace = new Namespace(slug, slug, "owner-1");
ReflectionTestUtils.setField(namespace, "id", id);

View file

@ -18,6 +18,7 @@ import com.iflytek.skillhub.domain.skill.SkillVersionRepository;
import com.iflytek.skillhub.domain.skill.SkillVisibility;
import com.iflytek.skillhub.domain.skill.service.SkillGovernanceService;
import com.iflytek.skillhub.domain.skill.service.SkillPublishService;
import com.iflytek.skillhub.domain.skill.service.SkillReviewSubmitService;
import com.iflytek.skillhub.domain.skill.service.SkillSlugResolutionService;
import com.iflytek.skillhub.dto.AdminSkillActionRequest;
import org.junit.jupiter.api.Test;
@ -33,6 +34,7 @@ class SkillLifecycleAppServiceTest {
private final SkillGovernanceService skillGovernanceService = mock(SkillGovernanceService.class);
private final ReviewService reviewService = mock(ReviewService.class);
private final SkillPublishService skillPublishService = mock(SkillPublishService.class);
private final SkillReviewSubmitService skillReviewSubmitService = mock(SkillReviewSubmitService.class);
private final AuditLogService auditLogService = mock(AuditLogService.class);
private final SkillSlugResolutionService skillSlugResolutionService = mock(SkillSlugResolutionService.class);
private final SkillLifecycleAppService service = new SkillLifecycleAppService(
@ -41,6 +43,7 @@ class SkillLifecycleAppServiceTest {
skillGovernanceService,
reviewService,
skillPublishService,
skillReviewSubmitService,
auditLogService,
skillSlugResolutionService
);

View file

@ -4,13 +4,14 @@ spring:
banner-mode: "off"
log-startup-info: false
datasource:
url: jdbc:h2:mem:testdb;MODE=PostgreSQL;DATABASE_TO_LOWER=TRUE;DEFAULT_NULL_ORDERING=HIGH;INIT=CREATE DOMAIN IF NOT EXISTS JSONB AS JSON;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE
generate-unique-name: true
url: jdbc:h2:mem:testdb-${random.uuid};MODE=PostgreSQL;DATABASE_TO_LOWER=TRUE;DEFAULT_NULL_ORDERING=HIGH;INIT=CREATE DOMAIN IF NOT EXISTS JSONB AS JSON;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE
driver-class-name: org.h2.Driver
username: sa
password:
jpa:
hibernate:
ddl-auto: create-drop
ddl-auto: create
database-platform: org.hibernate.dialect.H2Dialect
flyway:
enabled: false

View file

@ -35,6 +35,15 @@
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-mail</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-configuration-processor</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>

View file

@ -230,7 +230,7 @@ public class LocalAuthService {
private void validateEmail(String email) {
if (email == null) {
return;
throw new AuthFlowException(HttpStatus.BAD_REQUEST, "validation.auth.local.email.notBlank");
}
if (!EMAIL_PATTERN.matcher(email).matches()) {
throw new AuthFlowException(HttpStatus.BAD_REQUEST, "validation.auth.local.email.invalid");

View file

@ -0,0 +1,38 @@
package com.iflytek.skillhub.auth.local;
import java.time.Duration;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
@Component
@ConfigurationProperties(prefix = "skillhub.auth.password-reset")
public class PasswordResetProperties {
private Duration codeExpiry = Duration.ofMinutes(10);
private String emailFromAddress = "noreply@skillhub.local";
private String emailFromName = "SkillHub";
public Duration getCodeExpiry() {
return codeExpiry;
}
public void setCodeExpiry(Duration codeExpiry) {
this.codeExpiry = codeExpiry;
}
public String getEmailFromAddress() {
return emailFromAddress;
}
public void setEmailFromAddress(String emailFromAddress) {
this.emailFromAddress = emailFromAddress;
}
public String getEmailFromName() {
return emailFromName;
}
public void setEmailFromName(String emailFromName) {
this.emailFromName = emailFromName;
}
}

View file

@ -0,0 +1,244 @@
package com.iflytek.skillhub.auth.local;
import com.iflytek.skillhub.auth.exception.AuthFlowException;
import com.iflytek.skillhub.domain.auth.PasswordResetRequest;
import com.iflytek.skillhub.domain.auth.PasswordResetRequestRepository;
import com.iflytek.skillhub.domain.user.UserAccount;
import com.iflytek.skillhub.domain.user.UserAccountRepository;
import com.iflytek.skillhub.domain.user.UserStatus;
import java.security.SecureRandom;
import java.time.Instant;
import java.util.List;
import java.util.Locale;
import java.util.Optional;
import java.util.regex.Pattern;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.mail.SimpleMailMessage;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
/**
* Local-account password reset flow backed by one-time email verification
* codes.
*/
@Service
public class PasswordResetService {
private static final Logger log = LoggerFactory.getLogger(PasswordResetService.class);
private static final int VERIFICATION_CODE_DIGITS = 6;
private static final Pattern EMAIL_PATTERN = Pattern.compile("^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$");
private static final SecureRandom SECURE_RANDOM = new SecureRandom();
private final PasswordResetRequestRepository resetRequestRepository;
private final UserAccountRepository userAccountRepository;
private final LocalCredentialRepository credentialRepository;
private final PasswordPolicyValidator passwordPolicyValidator;
private final PasswordEncoder passwordEncoder;
private final JavaMailSender mailSender;
private final PasswordResetProperties properties;
public PasswordResetService(PasswordResetRequestRepository resetRequestRepository,
UserAccountRepository userAccountRepository,
LocalCredentialRepository credentialRepository,
PasswordPolicyValidator passwordPolicyValidator,
PasswordEncoder passwordEncoder,
JavaMailSender mailSender,
PasswordResetProperties properties) {
this.resetRequestRepository = resetRequestRepository;
this.userAccountRepository = userAccountRepository;
this.credentialRepository = credentialRepository;
this.passwordPolicyValidator = passwordPolicyValidator;
this.passwordEncoder = passwordEncoder;
this.mailSender = mailSender;
this.properties = properties;
}
/**
* Anonymous/self-service reset request. Always silent on ineligible users to
* avoid account enumeration.
*/
@Transactional
public void requestPasswordReset(String email) {
String normalizedEmail = normalizeEmail(email);
validateEmail(normalizedEmail);
Optional<UserAccount> userOpt = findEligibleUserByEmail(normalizedEmail);
if (userOpt.isEmpty()) {
log.debug("Password reset requested for ineligible email");
return;
}
UserAccount user = userOpt.get();
String code = generateVerificationCode();
Instant now = Instant.now();
Instant expiresAt = now.plus(properties.getCodeExpiry());
invalidatePendingRequests(user.getId(), now);
resetRequestRepository.save(new PasswordResetRequest(
user.getId(),
user.getEmail(),
passwordEncoder.encode(code),
expiresAt,
false,
null
));
sendVerificationCodeEmail(user.getEmail(), code, false);
}
/**
* Admin-triggered reset request for a specific user.
*/
@Transactional
public void adminTriggerPasswordReset(String userId, String adminUserId) {
UserAccount user = userAccountRepository.findById(userId)
.orElseThrow(() -> new AuthFlowException(HttpStatus.NOT_FOUND, "error.admin.user.notFound", userId));
if (!isEligibleForReset(user)) {
throw new AuthFlowException(HttpStatus.BAD_REQUEST, "error.auth.password.reset.not.eligible");
}
String code = generateVerificationCode();
Instant now = Instant.now();
Instant expiresAt = now.plus(properties.getCodeExpiry());
invalidatePendingRequests(userId, now);
resetRequestRepository.save(new PasswordResetRequest(
userId,
user.getEmail(),
passwordEncoder.encode(code),
expiresAt,
true,
adminUserId
));
sendVerificationCodeEmail(user.getEmail(), code, true);
}
/**
* Verifies a code and updates the local credential password.
*/
@Transactional
public void confirmPasswordReset(String email, String code, String newPassword) {
String normalizedEmail = normalizeEmail(email);
validateEmail(normalizedEmail);
UserAccount user = findUserByEmail(normalizedEmail)
.orElseThrow(() -> new AuthFlowException(HttpStatus.BAD_REQUEST, "error.auth.password.reset.invalid.code"));
List<PasswordResetRequest> pendingRequests = resetRequestRepository
.findByUserIdAndConsumedAtIsNullAndExpiresAtAfterOrderByCreatedAtDesc(user.getId(), Instant.now());
PasswordResetRequest matchedRequest = pendingRequests.stream()
.filter(request -> passwordEncoder.matches(code, request.getCodeHash()))
.findFirst()
.orElseThrow(() -> new AuthFlowException(HttpStatus.BAD_REQUEST, "error.auth.password.reset.invalid.code"));
var passwordErrors = passwordPolicyValidator.validate(newPassword);
if (!passwordErrors.isEmpty()) {
throw new AuthFlowException(HttpStatus.BAD_REQUEST, passwordErrors.getFirst());
}
LocalCredential credential = credentialRepository.findByUserId(user.getId())
.orElseThrow(() -> new AuthFlowException(HttpStatus.BAD_REQUEST, "error.auth.password.reset.no.credential"));
credential.setPasswordHash(passwordEncoder.encode(newPassword));
credential.setFailedAttempts(0);
credential.setLockedUntil(null);
credentialRepository.save(credential);
Instant now = Instant.now();
matchedRequest.markConsumed(now);
resetRequestRepository.save(matchedRequest);
invalidatePendingRequests(user.getId(), now);
}
private void invalidatePendingRequests(String userId, Instant now) {
List<PasswordResetRequest> pending = resetRequestRepository
.findByUserIdAndConsumedAtIsNullAndExpiresAtAfterOrderByCreatedAtDesc(userId, now);
for (PasswordResetRequest request : pending) {
request.markConsumed(now);
resetRequestRepository.save(request);
}
}
private Optional<UserAccount> findEligibleUserByEmail(String normalizedEmail) {
return findUserByEmail(normalizedEmail)
.filter(this::isEligibleForReset);
}
private Optional<UserAccount> findUserByEmail(String normalizedEmail) {
if (!StringUtils.hasText(normalizedEmail)) {
return Optional.empty();
}
return userAccountRepository.findByEmailIgnoreCase(normalizedEmail);
}
private boolean isEligibleForReset(UserAccount user) {
if (user.getStatus() != UserStatus.ACTIVE) {
return false;
}
if (!StringUtils.hasText(user.getEmail())) {
return false;
}
return credentialRepository.findByUserId(user.getId()).isPresent();
}
private String normalizeEmail(String email) {
if (email == null || email.isBlank()) {
return null;
}
return email.trim().toLowerCase(Locale.ROOT);
}
private void validateEmail(String email) {
if (email == null) {
throw new AuthFlowException(HttpStatus.BAD_REQUEST, "validation.auth.password.reset.email.notBlank");
}
if (!EMAIL_PATTERN.matcher(email).matches()) {
throw new AuthFlowException(HttpStatus.BAD_REQUEST, "validation.auth.password.reset.email.invalid");
}
}
private String generateVerificationCode() {
int bound = (int) Math.pow(10, VERIFICATION_CODE_DIGITS);
int code = SECURE_RANDOM.nextInt(bound);
return String.format("%0" + VERIFICATION_CODE_DIGITS + "d", code);
}
private void sendVerificationCodeEmail(String email, String code, boolean failOnError) {
SimpleMailMessage message = new SimpleMailMessage();
message.setFrom(resolveFromAddress());
message.setTo(email);
message.setSubject("SkillHub password reset verification code");
message.setText(buildVerificationCodeBody(code));
try {
mailSender.send(message);
log.info("Password reset verification code sent to {}", email);
} catch (Exception ex) {
if (failOnError) {
log.error("Failed to send password reset verification code to {}", email, ex);
throw new AuthFlowException(HttpStatus.INTERNAL_SERVER_ERROR, "error.auth.password.reset.email.failed");
}
log.warn("Failed to send password reset verification code to {}", email, ex);
}
}
private String resolveFromAddress() {
String fromAddress = properties.getEmailFromAddress();
if (!StringUtils.hasText(properties.getEmailFromName())) {
return fromAddress;
}
return properties.getEmailFromName() + " <" + fromAddress + ">";
}
private String buildVerificationCodeBody(String code) {
long expiryMinutes = Math.max(1L, properties.getCodeExpiry().toMinutes());
return "Your SkillHub password reset verification code is: " + code
+ "\n\nThis code expires in " + expiryMinutes + " minutes."
+ "\n\nIf you did not request a password reset, please ignore this email.";
}
}

View file

@ -1,5 +1,5 @@
/**
* Username-and-password authentication support, including registration,
* password changes, and local credential validation.
* password changes, password resets, and local credential validation.
*/
package com.iflytek.skillhub.auth.local;

View file

@ -71,10 +71,10 @@ public class RouteSecurityPolicyRegistry {
RouteAuthorizationPolicy.roles(HttpMethod.DELETE, "/api/v1/skills/*/*", "SUPER_ADMIN"),
RouteAuthorizationPolicy.authenticated(HttpMethod.DELETE, "/api/web/skills/id/*"),
RouteAuthorizationPolicy.authenticated(HttpMethod.DELETE, "/api/web/skills/*/*"),
RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/v1/namespaces"),
RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/v1/namespaces/*"),
RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/web/namespaces"),
RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/web/namespaces/*"),
RouteAuthorizationPolicy.authenticated(HttpMethod.GET, "/api/v1/namespaces"),
RouteAuthorizationPolicy.authenticated(HttpMethod.GET, "/api/v1/namespaces/*"),
RouteAuthorizationPolicy.authenticated(HttpMethod.GET, "/api/web/namespaces"),
RouteAuthorizationPolicy.authenticated(HttpMethod.GET, "/api/web/namespaces/*"),
RouteAuthorizationPolicy.authenticated(null, "/api/v1/admin/**")
);

View file

@ -238,4 +238,13 @@ class LocalAuthServiceTest {
.isInstanceOf(AuthFlowException.class)
.hasMessageContaining("validation.auth.local.email.invalid");
}
@Test
void register_rejectsBlankEmail() {
given(credentialRepository.existsByUsernameIgnoreCase("alice")).willReturn(false);
assertThatThrownBy(() -> service.register("Alice", "Abcd123!", " "))
.isInstanceOf(AuthFlowException.class)
.hasMessageContaining("validation.auth.local.email.notBlank");
}
}

View file

@ -0,0 +1,218 @@
package com.iflytek.skillhub.auth.local;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.atLeastOnce;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.BDDMockito.given;
import com.iflytek.skillhub.auth.exception.AuthFlowException;
import com.iflytek.skillhub.domain.auth.PasswordResetRequest;
import com.iflytek.skillhub.domain.auth.PasswordResetRequestRepository;
import com.iflytek.skillhub.domain.user.UserAccount;
import com.iflytek.skillhub.domain.user.UserAccountRepository;
import com.iflytek.skillhub.domain.user.UserStatus;
import java.time.Duration;
import java.time.Instant;
import java.util.List;
import java.util.Optional;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.http.HttpStatus;
import org.springframework.mail.SimpleMailMessage;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.security.crypto.password.PasswordEncoder;
@ExtendWith(MockitoExtension.class)
class PasswordResetServiceTest {
@Mock
private PasswordResetRequestRepository resetRequestRepository;
@Mock
private UserAccountRepository userAccountRepository;
@Mock
private LocalCredentialRepository credentialRepository;
@Mock
private PasswordEncoder passwordEncoder;
@Mock
private JavaMailSender mailSender;
private PasswordResetService service;
@BeforeEach
void setUp() {
PasswordResetProperties properties = new PasswordResetProperties();
properties.setCodeExpiry(Duration.ofMinutes(10));
properties.setEmailFromAddress("noreply@skillhub.local");
properties.setEmailFromName("SkillHub");
service = new PasswordResetService(
resetRequestRepository,
userAccountRepository,
credentialRepository,
new PasswordPolicyValidator(),
passwordEncoder,
mailSender,
properties
);
}
@Test
void requestPasswordReset_withEligibleEmail_savesRequestAndSendsEmail() {
UserAccount user = new UserAccount("usr_1", "alice", "alice@example.com", null);
given(userAccountRepository.findByEmailIgnoreCase("alice@example.com")).willReturn(Optional.of(user));
given(credentialRepository.findByUserId("usr_1")).willReturn(
Optional.of(new LocalCredential("usr_1", "alice", "encoded"))
);
given(resetRequestRepository.findByUserIdAndConsumedAtIsNullAndExpiresAtAfterOrderByCreatedAtDesc(
anyString(), any(Instant.class))
).willReturn(List.of());
given(passwordEncoder.encode(anyString())).willReturn("encoded-value");
service.requestPasswordReset("alice@example.com");
verify(resetRequestRepository).save(any(PasswordResetRequest.class));
verify(mailSender).send(any(SimpleMailMessage.class));
}
@Test
void requestPasswordReset_withUnknownEmail_doesNothing() {
given(userAccountRepository.findByEmailIgnoreCase("ghost@example.com")).willReturn(Optional.empty());
service.requestPasswordReset("ghost@example.com");
verify(resetRequestRepository, never()).save(any(PasswordResetRequest.class));
verify(mailSender, never()).send(any(SimpleMailMessage.class));
}
@Test
void requestPasswordReset_withInvalidEmail_throwsBadRequest() {
assertThatThrownBy(() -> service.requestPasswordReset("alice"))
.isInstanceOf(AuthFlowException.class)
.extracting("status")
.isEqualTo(HttpStatus.BAD_REQUEST);
verifyNoInteractions(userAccountRepository, resetRequestRepository, mailSender);
}
@Test
void requestPasswordReset_emailFailure_doesNotThrowForAnonymousFlow() {
UserAccount user = new UserAccount("usr_1", "alice", "alice@example.com", null);
given(userAccountRepository.findByEmailIgnoreCase("alice@example.com")).willReturn(Optional.of(user));
given(credentialRepository.findByUserId("usr_1")).willReturn(
Optional.of(new LocalCredential("usr_1", "alice", "encoded"))
);
given(resetRequestRepository.findByUserIdAndConsumedAtIsNullAndExpiresAtAfterOrderByCreatedAtDesc(
anyString(), any(Instant.class))
).willReturn(List.of());
given(passwordEncoder.encode(anyString())).willReturn("encoded-value");
org.mockito.Mockito.doThrow(new RuntimeException("smtp down")).when(mailSender).send(any(SimpleMailMessage.class));
service.requestPasswordReset("alice@example.com");
verify(resetRequestRepository).save(any(PasswordResetRequest.class));
}
@Test
void adminTriggerPasswordReset_withUnknownUser_throwsNotFound() {
given(userAccountRepository.findById("missing")).willReturn(Optional.empty());
assertThatThrownBy(() -> service.adminTriggerPasswordReset("missing", "admin_1"))
.isInstanceOf(AuthFlowException.class)
.extracting("status")
.isEqualTo(HttpStatus.NOT_FOUND);
}
@Test
void confirmPasswordReset_withValidCode_updatesCredential() {
UserAccount user = new UserAccount("usr_1", "alice", "alice@example.com", null);
LocalCredential credential = new LocalCredential("usr_1", "alice", "old-password");
PasswordResetRequest request = new PasswordResetRequest(
"usr_1",
"alice@example.com",
"encoded-code",
Instant.now().plus(Duration.ofMinutes(5)),
false,
null
);
given(userAccountRepository.findByEmailIgnoreCase("alice@example.com")).willReturn(Optional.of(user));
given(resetRequestRepository.findByUserIdAndConsumedAtIsNullAndExpiresAtAfterOrderByCreatedAtDesc(
anyString(), any(Instant.class))
).willReturn(List.of(request));
given(passwordEncoder.matches("123456", "encoded-code")).willReturn(true);
given(credentialRepository.findByUserId("usr_1")).willReturn(Optional.of(credential));
given(passwordEncoder.encode("Abcd123!")).willReturn("new-password-hash");
service.confirmPasswordReset("alice@example.com", "123456", "Abcd123!");
assertThat(credential.getPasswordHash()).isEqualTo("new-password-hash");
assertThat(credential.getFailedAttempts()).isZero();
assertThat(credential.getLockedUntil()).isNull();
verify(credentialRepository).save(credential);
ArgumentCaptor<PasswordResetRequest> requestCaptor = ArgumentCaptor.forClass(PasswordResetRequest.class);
verify(resetRequestRepository, atLeastOnce()).save(requestCaptor.capture());
assertThat(requestCaptor.getAllValues())
.anySatisfy(captured -> assertThat(captured.getConsumedAt()).isNotNull());
}
@Test
void confirmPasswordReset_withInvalidCode_throwsBadRequest() {
UserAccount user = new UserAccount("usr_1", "alice", "alice@example.com", null);
LocalCredential credential = new LocalCredential("usr_1", "alice", "old-password");
PasswordResetRequest request = new PasswordResetRequest(
"usr_1",
"alice@example.com",
"encoded-code",
Instant.now().plus(Duration.ofMinutes(5)),
false,
null
);
given(userAccountRepository.findByEmailIgnoreCase("alice@example.com")).willReturn(Optional.of(user));
given(resetRequestRepository.findByUserIdAndConsumedAtIsNullAndExpiresAtAfterOrderByCreatedAtDesc(
anyString(), any(Instant.class))
).willReturn(List.of(request));
given(passwordEncoder.matches("654321", "encoded-code")).willReturn(false);
assertThatThrownBy(() -> service.confirmPasswordReset("alice@example.com", "654321", "Abcd123!"))
.isInstanceOf(AuthFlowException.class)
.extracting("status")
.isEqualTo(HttpStatus.BAD_REQUEST);
}
@Test
void confirmPasswordReset_withInvalidEmail_throwsBadRequest() {
assertThatThrownBy(() -> service.confirmPasswordReset("alice", "123456", "Abcd123!"))
.isInstanceOf(AuthFlowException.class)
.extracting("status")
.isEqualTo(HttpStatus.BAD_REQUEST);
verifyNoInteractions(userAccountRepository, resetRequestRepository, credentialRepository);
}
@Test
void adminTriggerPasswordReset_forDisabledUser_throwsBadRequest() {
UserAccount user = new UserAccount("usr_1", "alice", "alice@example.com", null);
user.setStatus(UserStatus.DISABLED);
given(userAccountRepository.findById("usr_1")).willReturn(Optional.of(user));
assertThatThrownBy(() -> service.adminTriggerPasswordReset("usr_1", "admin_1"))
.isInstanceOf(AuthFlowException.class)
.extracting("status")
.isEqualTo(HttpStatus.BAD_REQUEST);
}
}

View file

@ -58,6 +58,21 @@ class RouteSecurityPolicyRegistryTest {
assertTrue(matchedWeb);
}
@Test
void authorizationPolicies_shouldRequireAuthenticationForNamespaceDiscovery() {
boolean matchedV1 = registry.authorizationPolicies().stream()
.anyMatch(policy -> policy.method() == HttpMethod.GET
&& "/api/v1/namespaces".equals(policy.pattern())
&& policy.accessLevel() == RouteSecurityPolicyRegistry.AccessLevel.AUTHENTICATED);
boolean matchedWeb = registry.authorizationPolicies().stream()
.anyMatch(policy -> policy.method() == HttpMethod.GET
&& "/api/web/namespaces".equals(policy.pattern())
&& policy.accessLevel() == RouteSecurityPolicyRegistry.AccessLevel.AUTHENTICATED);
assertTrue(matchedV1);
assertTrue(matchedWeb);
}
@Test
void shouldIgnoreCsrf_forBearerAndApiPaths() {
assertTrue(registry.shouldIgnoreCsrf("/api/v1/admin/users", null));

View file

@ -0,0 +1,108 @@
package com.iflytek.skillhub.domain.auth;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.PrePersist;
import jakarta.persistence.Table;
import java.time.Clock;
import java.time.Instant;
@Entity
@Table(name = "password_reset_request")
public class PasswordResetRequest {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "user_id", nullable = false, length = 128)
private String userId;
@Column(nullable = false, length = 255)
private String email;
@Column(name = "code_hash", nullable = false, length = 255)
private String codeHash;
@Column(name = "expires_at", nullable = false)
private Instant expiresAt;
@Column(name = "consumed_at")
private Instant consumedAt;
@Column(name = "requested_by_admin", nullable = false)
private boolean requestedByAdmin;
@Column(name = "requested_by_user_id", length = 128)
private String requestedByUserId;
@Column(name = "created_at", nullable = false, updatable = false)
private Instant createdAt;
protected PasswordResetRequest() {
}
public PasswordResetRequest(String userId,
String email,
String codeHash,
Instant expiresAt,
boolean requestedByAdmin,
String requestedByUserId) {
this.userId = userId;
this.email = email;
this.codeHash = codeHash;
this.expiresAt = expiresAt;
this.requestedByAdmin = requestedByAdmin;
this.requestedByUserId = requestedByUserId;
}
@PrePersist
void prePersist() {
if (createdAt == null) {
createdAt = Instant.now(Clock.systemUTC());
}
}
public void markConsumed(Instant timestamp) {
this.consumedAt = timestamp;
}
public Long getId() {
return id;
}
public String getUserId() {
return userId;
}
public String getEmail() {
return email;
}
public String getCodeHash() {
return codeHash;
}
public Instant getExpiresAt() {
return expiresAt;
}
public Instant getConsumedAt() {
return consumedAt;
}
public boolean isRequestedByAdmin() {
return requestedByAdmin;
}
public String getRequestedByUserId() {
return requestedByUserId;
}
public Instant getCreatedAt() {
return createdAt;
}
}

View file

@ -0,0 +1,17 @@
package com.iflytek.skillhub.domain.auth;
import java.time.Instant;
import java.util.List;
/**
* Domain repository contract for local-account password reset verification
* codes.
*/
public interface PasswordResetRequestRepository {
PasswordResetRequest save(PasswordResetRequest request);
List<PasswordResetRequest> findByUserIdAndConsumedAtIsNullAndExpiresAtAfterOrderByCreatedAtDesc(
String userId,
Instant now
);
}

View file

@ -0,0 +1,4 @@
/**
* Password-reset domain entities and repository contracts.
*/
package com.iflytek.skillhub.domain.auth;

View file

@ -28,7 +28,8 @@ public class ReviewPermissionChecker {
Map<Long, NamespaceRole> userNamespaceRoles,
Set<String> platformRoles) {
if (task.getSubmittedBy().equals(userId)) {
return platformRoles.contains("SUPER_ADMIN");
return platformRoles.contains("SUPER_ADMIN")
|| canSelfReviewNamespace(task.getNamespaceId(), namespaceType, userNamespaceRoles);
}
return canReviewNamespace(task.getNamespaceId(), namespaceType, userNamespaceRoles, platformRoles);
}
@ -135,4 +136,15 @@ public class ReviewPermissionChecker {
return platformRoles.contains("SKILL_ADMIN")
|| platformRoles.contains("SUPER_ADMIN");
}
private boolean canSelfReviewNamespace(Long namespaceId,
NamespaceType namespaceType,
Map<Long, NamespaceRole> userNamespaceRoles) {
if (namespaceType == NamespaceType.GLOBAL) {
return false;
}
NamespaceRole role = userNamespaceRoles.get(namespaceId);
return role == NamespaceRole.OWNER || role == NamespaceRole.ADMIN;
}
}

View file

@ -101,7 +101,9 @@ public class ReviewService {
throw new DomainForbiddenException("review.submit.no_permission");
}
if (skillVersion.getStatus() != SkillVersionStatus.DRAFT) {
// Support both DRAFT (legacy) and UPLOADED (new flow) status
if (skillVersion.getStatus() != SkillVersionStatus.DRAFT
&& skillVersion.getStatus() != SkillVersionStatus.UPLOADED) {
throw new DomainBadRequestException("review.submit.not_draft", skillVersionId);
}
@ -137,7 +139,9 @@ public class ReviewService {
.orElseThrow(() -> new DomainNotFoundException("namespace.not_found", skill.getNamespaceId()));
assertNamespaceActive(namespace);
if (skillVersion.getStatus() != SkillVersionStatus.DRAFT) {
// Support both DRAFT (legacy) and UPLOADED (new flow) status
if (skillVersion.getStatus() != SkillVersionStatus.DRAFT
&& skillVersion.getStatus() != SkillVersionStatus.UPLOADED) {
throw new DomainBadRequestException("review.submit.not_draft", skillVersionId);
}

View file

@ -2,6 +2,7 @@ package com.iflytek.skillhub.domain.security;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.iflytek.skillhub.domain.skill.SkillVisibility;
import com.iflytek.skillhub.domain.skill.SkillVersion;
import com.iflytek.skillhub.domain.skill.SkillVersionRepository;
import com.iflytek.skillhub.domain.skill.SkillVersionStatus;
@ -105,7 +106,12 @@ public class SecurityScanService {
audit.setScannedAt(Instant.now(Clock.systemUTC()));
auditRepository.save(audit);
version.setStatus(SkillVersionStatus.PENDING_REVIEW);
// Set status based on requestedVisibility
if (version.getRequestedVisibility() == SkillVisibility.PRIVATE) {
version.setStatus(SkillVersionStatus.UPLOADED);
} else {
version.setStatus(SkillVersionStatus.PENDING_REVIEW);
}
skillVersionRepository.save(version);
}

View file

@ -4,6 +4,7 @@ public enum SkillVersionStatus {
DRAFT,
SCANNING,
SCAN_FAILED,
UPLOADED,
PENDING_REVIEW,
PUBLISHED,
REJECTED,

View file

@ -3,6 +3,7 @@ package com.iflytek.skillhub.domain.skill;
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
import java.util.Map;
import java.util.Set;
/**
* Evaluates whether a caller may read a skill based on publication state, visibility, ownership,
@ -11,6 +12,13 @@ import java.util.Map;
public class VisibilityChecker {
public boolean canAccess(Skill skill, String currentUserId, Map<Long, NamespaceRole> userNamespaceRoles) {
return canAccess(skill, currentUserId, userNamespaceRoles, Set.of());
}
public boolean canAccess(Skill skill, String currentUserId, Map<Long, NamespaceRole> userNamespaceRoles, Set<String> platformRoles) {
if (isSuperAdmin(platformRoles)) {
return true;
}
if (skill.isHidden()) {
return isOwner(skill, currentUserId) || isAdminOrAbove(userNamespaceRoles.get(skill.getNamespaceId()));
}
@ -31,4 +39,8 @@ public class VisibilityChecker {
private boolean isAdminOrAbove(NamespaceRole role) {
return role == NamespaceRole.ADMIN || role == NamespaceRole.OWNER;
}
private boolean isSuperAdmin(Set<String> platformRoles) {
return platformRoles != null && platformRoles.contains("SUPER_ADMIN");
}
}

View file

@ -164,12 +164,15 @@ public class SkillDownloadService {
private DownloadResult downloadVersion(Skill skill, SkillVersion version) {
assertPublishedAccessible(skill);
assertPublishedVersion(version);
assertDownloadableVersion(skill, version);
DownloadResult result = buildDownloadResult(skill, version);
skillRepository.incrementDownloadCount(skill.getId());
skillVersionStatsRepository.incrementDownloadCount(version.getId(), skill.getId());
eventPublisher.publishEvent(new SkillDownloadedEvent(skill.getId(), version.getId()));
// Only increment download count for PUBLISHED versions
if (version.getStatus() == SkillVersionStatus.PUBLISHED) {
skillRepository.incrementDownloadCount(skill.getId());
skillVersionStatsRepository.incrementDownloadCount(version.getId(), skill.getId());
eventPublisher.publishEvent(new SkillDownloadedEvent(skill.getId(), version.getId()));
}
return result;
}
@ -292,9 +295,21 @@ public class SkillDownloadService {
}
}
private void assertPublishedVersion(SkillVersion version) {
if (version.getStatus() != SkillVersionStatus.PUBLISHED) {
throw new DomainBadRequestException("error.skill.version.notPublished", version.getVersion());
/**
* Asserts that the version can be downloaded.
* - PUBLISHED: anyone with skill access can download
* - UPLOADED/PENDING_REVIEW: only skill owner can download
*/
private void assertDownloadableVersion(Skill skill, SkillVersion version) {
switch (version.getStatus()) {
case PUBLISHED -> {
// Anyone with skill access can download published versions
}
case UPLOADED, PENDING_REVIEW -> {
// Only owner can download UPLOADED/PENDING_REVIEW versions
// Note: This check is already done in assertCanDownload via visibilityChecker
}
default -> throw new DomainBadRequestException("error.skill.version.notDownloadable", version.getVersion());
}
}
}

View file

@ -162,7 +162,8 @@ public class SkillGovernanceService {
assertCanManageLifecycle(skill, actorUserId, userNamespaceRoles);
if (version.getStatus() != SkillVersionStatus.DRAFT
&& version.getStatus() != SkillVersionStatus.REJECTED
&& version.getStatus() != SkillVersionStatus.SCAN_FAILED) {
&& version.getStatus() != SkillVersionStatus.SCAN_FAILED
&& version.getStatus() != SkillVersionStatus.UPLOADED) {
throw new DomainBadRequestException("error.skill.version.delete.unsupported", version.getVersion());
}
@ -242,7 +243,7 @@ public class SkillGovernanceService {
if (version.getStatus() != SkillVersionStatus.PENDING_REVIEW) {
throw new DomainBadRequestException("review.withdraw.not_pending", version.getId());
}
version.setStatus(SkillVersionStatus.DRAFT);
version.setStatus(SkillVersionStatus.UPLOADED);
SkillVersion savedVersion = skillVersionRepository.save(version);
skill.setUpdatedBy(actorUserId);
skillRepository.save(skill);

View file

@ -132,7 +132,18 @@ public class SkillPublishService {
String publisherId,
SkillVisibility visibility,
java.util.Set<String> platformRoles) {
return publishFromEntriesInternal(namespaceSlug, entries, publisherId, visibility, platformRoles, false, false);
return publishFromEntries(namespaceSlug, entries, publisherId, visibility, platformRoles, false);
}
@Transactional
public PublishResult publishFromEntries(
String namespaceSlug,
List<PackageEntry> entries,
String publisherId,
SkillVisibility visibility,
java.util.Set<String> platformRoles,
boolean confirmWarnings) {
return publishFromEntriesInternal(namespaceSlug, entries, publisherId, visibility, platformRoles, confirmWarnings, false, false);
}
/**
@ -145,7 +156,8 @@ public class SkillPublishService {
String sourceVersion,
String targetVersion,
String publisherId,
Map<Long, NamespaceRole> userNamespaceRoles) {
Map<Long, NamespaceRole> userNamespaceRoles,
boolean confirmWarnings) {
Skill skill = skillRepository.findById(skillId)
.orElseThrow(() -> new DomainBadRequestException("error.skill.notFound", skillId));
assertCanManageLifecycle(skill, publisherId, userNamespaceRoles);
@ -161,13 +173,17 @@ public class SkillPublishService {
List<PackageEntry> entries = rebuildEntriesForRerelease(skillId, publishedVersion.getId(), targetVersion);
// Rerelease follows the same visibility-based workflow as normal publish:
// - PRIVATE skills go to UPLOADED status
// - PUBLIC/NAMESPACE_ONLY skills go to PENDING_REVIEW (or UPLOADED after scan)
return publishFromEntriesInternal(
resolveNamespaceSlug(skill.getNamespaceId()),
entries,
publisherId,
skill.getVisibility(),
Set.of(),
true,
confirmWarnings, // confirmWarnings: honour caller's choice for rerelease
false, // forceAutoPublish=false: respect visibility rules
true
);
}
@ -178,6 +194,7 @@ public class SkillPublishService {
String publisherId,
SkillVisibility visibility,
Set<String> platformRoles,
boolean confirmWarnings,
boolean forceAutoPublish,
boolean bypassMembershipCheck) {
@ -225,18 +242,31 @@ public class SkillPublishService {
"error.skill.publish.precheck.failed",
String.join(", ", prePublishValidation.errors()));
}
List<String> publishWarnings = new ArrayList<>(packageValidation.warnings());
publishWarnings.addAll(prePublishValidation.warnings());
if (!confirmWarnings && !publishWarnings.isEmpty()) {
throw new DomainBadRequestException(
"error.skill.publish.precheck.confirmRequired",
formatValidationMessages(publishWarnings));
}
// 6. Find or create Skill record (with owner isolation)
List<Skill> existingSkills = skillRepository.findByNamespaceIdAndSlug(namespace.getId(), skillSlug);
// Check if any other owner's skill has published versions
// Only PUBLISHED status blocks same-name publishing (UPLOADED/PENDING_REVIEW allowed)
for (Skill existing : existingSkills) {
if (!existing.getOwnerId().equals(publisherId)) {
boolean hasPublished = !skillVersionRepository
.findBySkillIdAndStatus(existing.getId(), SkillVersionStatus.PUBLISHED)
.isEmpty();
if (hasPublished) {
throw new DomainBadRequestException("error.skill.publish.nameConflict", skillSlug);
// Distinguish between PRIVATE and PUBLIC/NAMESPACE_ONLY conflicts
if (existing.getVisibility() == SkillVisibility.PRIVATE) {
throw new DomainBadRequestException("error.skill.publish.nameConflict.private", skillSlug);
} else {
throw new DomainBadRequestException("error.skill.publish.nameConflict", skillSlug);
}
}
}
}
@ -254,12 +284,13 @@ public class SkillPublishService {
}
// 6c. Auto-withdraw pending review versions
// When publishing a new version, existing PENDING_REVIEW versions are withdrawn to UPLOADED status
List<SkillVersion> pendingVersions = skillVersionRepository
.findBySkillIdAndStatus(skill.getId(), SkillVersionStatus.PENDING_REVIEW);
for (SkillVersion pending : pendingVersions) {
reviewTaskRepository.findBySkillVersionIdAndStatus(pending.getId(), ReviewTaskStatus.PENDING)
.ifPresent(reviewTaskRepository::delete);
pending.setStatus(SkillVersionStatus.DRAFT);
pending.setStatus(SkillVersionStatus.UPLOADED);
skillVersionRepository.save(pending);
}
@ -280,6 +311,10 @@ public class SkillPublishService {
if (autoPublish) {
version.setStatus(SkillVersionStatus.PUBLISHED);
version.setPublishedAt(currentTime());
} else if (visibility == SkillVisibility.PRIVATE) {
// PRIVATE skill goes to UPLOADED status, no review task created
version.setStatus(SkillVersionStatus.UPLOADED);
version.setPublishedAt(currentTime());
} else {
version.setStatus(SkillVersionStatus.PENDING_REVIEW);
}
@ -356,7 +391,8 @@ public class SkillPublishService {
version.setDownloadReady(!skillFiles.isEmpty());
skillVersionRepository.save(version);
if (!autoPublish) {
// Create review task for PUBLIC/NAMESPACE_ONLY (not PRIVATE)
if (!autoPublish && visibility != SkillVisibility.PRIVATE) {
ReviewTask reviewTask = new ReviewTask(version.getId(), namespace.getId(), publisherId);
ReviewTask savedReviewTask = reviewTaskRepository.save(reviewTask);
eventPublisher.publishEvent(new ReviewSubmittedEvent(
@ -366,15 +402,18 @@ public class SkillPublishService {
savedReviewTask.getSubmittedBy(),
savedReviewTask.getNamespaceId()
));
if (securityScanService.isEnabled()) {
securityScanService.triggerScan(version.getId(), entries, publisherId);
}
}
// Trigger security scan for all non-autoPublish versions
if (!autoPublish && securityScanService.isEnabled()) {
securityScanService.triggerScan(version.getId(), entries, publisherId);
}
// 12. Update skill metadata and move the published pointer for auto-publish flows
skill.setDisplayName(metadata.name());
skill.setSummary(metadata.description());
if (autoPublish) {
if (autoPublish || visibility == SkillVisibility.PRIVATE) {
// Update latestVersionId for autoPublish or PRIVATE skill (UPLOADED status)
skill.setLatestVersionId(version.getId());
skill.setVisibility(visibility);
}
@ -457,6 +496,12 @@ public class SkillPublishService {
return String.format("packages/%d/%d/bundle.zip", skillId, versionId);
}
private String formatValidationMessages(List<String> warnings) {
return warnings.stream()
.map(warning -> "- " + warning)
.reduce("", (left, right) -> left.isEmpty() ? right : left + "\n" + right);
}
private void assertNamespaceWritable(Namespace namespace) {
if (namespace.getStatus() == NamespaceStatus.FROZEN) {
throw new DomainBadRequestException("error.namespace.frozen", namespace.getSlug());

View file

@ -30,6 +30,7 @@ import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.stream.Collectors;
/**
@ -149,11 +150,14 @@ public class SkillQueryService {
String skillSlug,
String currentUserId,
Map<Long, NamespaceRole> userNsRoles) {
Namespace namespace = findNamespace(namespaceSlug);
Skill skill = resolveVisibleSkill(namespace.getId(), skillSlug, currentUserId);
// Visibility check
if (namespace.getStatus() == com.iflytek.skillhub.domain.namespace.NamespaceStatus.ARCHIVED
&& !isNamespaceMember(namespace.getId(), currentUserId, userNsRoles)) {
throw new DomainForbiddenException("error.namespace.archived", namespaceSlug);
}
if (!visibilityChecker.canAccess(skill, currentUserId, userNsRoles)) {
throw new DomainForbiddenException("error.skill.access.denied", skillSlug);
}
@ -198,6 +202,15 @@ public class SkillQueryService {
);
}
public SkillDetailDTO getSkillDetail(
String namespaceSlug,
String skillSlug,
String currentUserId,
Map<Long, NamespaceRole> userNsRoles,
Set<String> platformRoles) {
return getSkillDetail(namespaceSlug, skillSlug, currentUserId, userNsRoles);
}
/**
* Lists skills within a namespace after filtering out records the caller is
* not allowed to discover.
@ -333,6 +346,7 @@ public class SkillQueryService {
visibleVersions = skillVersionRepository.findBySkillId(skill.getId()).stream()
.filter(version -> version.getStatus() == SkillVersionStatus.PUBLISHED
|| version.getStatus() == SkillVersionStatus.PENDING_REVIEW
|| version.getStatus() == SkillVersionStatus.UPLOADED
|| version.getStatus() == SkillVersionStatus.DRAFT
|| version.getStatus() == SkillVersionStatus.REJECTED
|| version.getStatus() == SkillVersionStatus.YANKED
@ -382,6 +396,7 @@ public class SkillQueryService {
List<SkillVersion> versions = skillVersionRepository.findBySkillId(skill.getId()).stream()
.filter(version -> version.getStatus() == SkillVersionStatus.PUBLISHED
|| version.getStatus() == SkillVersionStatus.PENDING_REVIEW
|| version.getStatus() == SkillVersionStatus.UPLOADED
|| version.getStatus() == SkillVersionStatus.DRAFT
|| version.getStatus() == SkillVersionStatus.REJECTED
|| version.getStatus() == SkillVersionStatus.YANKED
@ -689,15 +704,18 @@ public class SkillQueryService {
if (status == SkillVersionStatus.SCAN_FAILED) {
return 1;
}
if (status == SkillVersionStatus.REJECTED) {
if (status == SkillVersionStatus.UPLOADED) {
return 2;
}
if (status == SkillVersionStatus.PENDING_REVIEW) {
if (status == SkillVersionStatus.REJECTED) {
return 3;
}
if (status == SkillVersionStatus.DRAFT) {
if (status == SkillVersionStatus.PENDING_REVIEW) {
return 4;
}
if (status == SkillVersionStatus.DRAFT) {
return 5;
}
if (status == SkillVersionStatus.YANKED) {
return 5;
}

View file

@ -0,0 +1,159 @@
package com.iflytek.skillhub.domain.skill.service;
import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository;
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
import com.iflytek.skillhub.domain.review.ReviewTask;
import com.iflytek.skillhub.domain.review.ReviewTaskRepository;
import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException;
import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException;
import com.iflytek.skillhub.domain.skill.*;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.Clock;
import java.time.Instant;
import java.util.Map;
/**
* Service for submitting skill versions for review and confirming private publishes.
*
* <p>This service handles two key workflows for UPLOADED skill versions:
* <ul>
* <li><b>submitForReview</b>: Transitions an UPLOADED version to PENDING_REVIEW status,
* creating a review task for PUBLIC/NAMESPACE_ONLY visibility changes.</li>
* <li><b>confirmPublish</b>: Transitions an UPLOADED version directly to PUBLISHED status
* for PRIVATE skills without requiring review.</li>
* </ul>
*
* @see SkillVersionStatus#UPLOADED
* @see SkillVisibility#PRIVATE
*/
@Service
public class SkillReviewSubmitService {
private final SkillRepository skillRepository;
private final SkillVersionRepository skillVersionRepository;
private final ReviewTaskRepository reviewTaskRepository;
private final NamespaceMemberRepository namespaceMemberRepository;
private final ApplicationEventPublisher eventPublisher;
private final Clock clock;
public SkillReviewSubmitService(
SkillRepository skillRepository,
SkillVersionRepository skillVersionRepository,
ReviewTaskRepository reviewTaskRepository,
NamespaceMemberRepository namespaceMemberRepository,
ApplicationEventPublisher eventPublisher,
Clock clock) {
this.skillRepository = skillRepository;
this.skillVersionRepository = skillVersionRepository;
this.reviewTaskRepository = reviewTaskRepository;
this.namespaceMemberRepository = namespaceMemberRepository;
this.eventPublisher = eventPublisher;
this.clock = clock;
}
/**
* Submit an UPLOADED or DRAFT version for review.
* Transitions version status from UPLOADED/DRAFT to PENDING_REVIEW.
*
* <p>Supports both UPLOADED (new flow) and DRAFT (legacy compatibility) status.
*
* @param skillId the skill ID
* @param versionId the version ID
* @param targetVisibility the target visibility after approval
* @param actorUserId the user performing the action
* @param userNamespaceRoles user's namespace roles
*/
@Transactional
public void submitForReview(Long skillId, Long versionId, SkillVisibility targetVisibility,
String actorUserId, Map<Long, NamespaceRole> userNamespaceRoles) {
Skill skill = skillRepository.findById(skillId)
.orElseThrow(() -> new DomainBadRequestException("error.skill.notFound", skillId));
SkillVersion version = skillVersionRepository.findById(versionId)
.orElseThrow(() -> new DomainBadRequestException("error.skill.version.notFound", versionId));
// Validate ownership
assertCanManageLifecycle(skill, actorUserId, userNamespaceRoles);
// Validate version status - support both UPLOADED (new) and DRAFT (legacy)
if (version.getStatus() != SkillVersionStatus.UPLOADED
&& version.getStatus() != SkillVersionStatus.DRAFT) {
throw new DomainBadRequestException("error.skill.version.submit.notUploaded", version.getVersion());
}
// Validate version belongs to skill
if (!version.getSkillId().equals(skillId)) {
throw new DomainBadRequestException("error.skill.version.mismatch");
}
// Update version
version.setStatus(SkillVersionStatus.PENDING_REVIEW);
version.setRequestedVisibility(targetVisibility);
skillVersionRepository.save(version);
// Create review task
ReviewTask reviewTask = new ReviewTask(versionId, skill.getNamespaceId(), actorUserId);
reviewTaskRepository.save(reviewTask);
}
/**
* Confirm publish for a PRIVATE skill version.
* Transitions version status from UPLOADED/DRAFT to PUBLISHED without review.
*
* <p>Supports both UPLOADED (new flow) and DRAFT (legacy compatibility) status.
*
* @param skillId the skill ID
* @param versionId the version ID
* @param actorUserId the user performing the action
* @param userNamespaceRoles user's namespace roles
*/
@Transactional
public void confirmPublish(Long skillId, Long versionId, String actorUserId,
Map<Long, NamespaceRole> userNamespaceRoles) {
Skill skill = skillRepository.findById(skillId)
.orElseThrow(() -> new DomainBadRequestException("error.skill.notFound", skillId));
SkillVersion version = skillVersionRepository.findById(versionId)
.orElseThrow(() -> new DomainBadRequestException("error.skill.version.notFound", versionId));
// Validate ownership
assertCanManageLifecycle(skill, actorUserId, userNamespaceRoles);
// Validate skill visibility is PRIVATE
if (skill.getVisibility() != SkillVisibility.PRIVATE) {
throw new DomainBadRequestException("error.skill.confirm.notPrivate");
}
// Validate version status - support both UPLOADED (new) and DRAFT (legacy)
if (version.getStatus() != SkillVersionStatus.UPLOADED
&& version.getStatus() != SkillVersionStatus.DRAFT) {
throw new DomainBadRequestException("error.skill.version.confirm.notUploaded", version.getVersion());
}
// Validate version belongs to skill
if (!version.getSkillId().equals(skillId)) {
throw new DomainBadRequestException("error.skill.version.mismatch");
}
// Update version to PUBLISHED
version.setStatus(SkillVersionStatus.PUBLISHED);
version.setPublishedAt(Instant.now(clock));
skillVersionRepository.save(version);
// Update skill's latest version
skill.setLatestVersionId(versionId);
skill.setUpdatedBy(actorUserId);
skillRepository.save(skill);
}
private void assertCanManageLifecycle(Skill skill, String actorUserId, Map<Long, NamespaceRole> userNamespaceRoles) {
NamespaceRole namespaceRole = userNamespaceRoles.get(skill.getNamespaceId());
boolean canManage = skill.getOwnerId().equals(actorUserId)
|| namespaceRole == NamespaceRole.ADMIN
|| namespaceRole == NamespaceRole.OWNER;
if (!canManage) {
throw new DomainForbiddenException("error.skill.lifecycle.noPermission");
}
}
}

View file

@ -16,12 +16,6 @@ import java.util.regex.Pattern;
@Component
public class BasicPrePublishValidator implements PrePublishValidator {
private static final Pattern ASSIGNMENT_WITH_SENSITIVE_KEY = Pattern.compile(
"(?i)(api[_-]?key|access[_-]?key|secret|password|token)\\s*[:=]\\s*(.+)$"
);
private static final Pattern QUOTED_LITERAL = Pattern.compile("^(['\"])(.*)\\1$");
private static final Pattern IDENTIFIER = Pattern.compile("[A-Za-z_][A-Za-z0-9_]*");
private static final Pattern BARE_LITERAL = Pattern.compile("[A-Za-z0-9_\\-]{12,}");
private static final Pattern PLACEHOLDER_VALUE = Pattern.compile(
"(?i).*(your|example|sample|placeholder|changeme|replace|dummy|mock|test|fake|todo|xxx|redacted).*"
);
@ -29,12 +23,15 @@ public class BasicPrePublishValidator implements PrePublishValidator {
new SecretRule(Pattern.compile("(AKIA[0-9A-Z]{16})"), 1, "cloud access key"),
new SecretRule(Pattern.compile("(ghp_[A-Za-z0-9]{20,})"), 1, "GitHub token"),
new SecretRule(Pattern.compile("(sk-[A-Za-z0-9]{20,})"), 1, "API key"),
new SecretRule(ASSIGNMENT_WITH_SENSITIVE_KEY, 0, "secret or token")
new SecretRule(
Pattern.compile("(?i)(api[_-]?key|access[_-]?key|secret|password|token)\\s*[:=]\\s*['\\\"]?([A-Za-z0-9_\\-]{12,})"),
2,
"secret or token")
);
@Override
public ValidationResult validate(SkillPackageContext context) {
List<String> errors = new ArrayList<>();
List<String> warnings = new ArrayList<>();
for (PackageEntry entry : context.entries()) {
if (!isTextLike(entry.path())) {
@ -49,14 +46,11 @@ public class BasicPrePublishValidator implements PrePublishValidator {
if (!matcher.find()) {
continue;
}
String matchedValue = extractMatchedValue(line, matcher, rule);
if (matchedValue == null) {
continue;
}
String matchedValue = matcher.group(rule.valueGroup());
if (isPlaceholderValue(matchedValue)) {
continue;
}
errors.add(entry.path()
warnings.add(entry.path()
+ " line " + (i + 1)
+ " contains a value that looks like a "
+ rule.label()
@ -66,7 +60,7 @@ public class BasicPrePublishValidator implements PrePublishValidator {
}
}
return errors.isEmpty() ? ValidationResult.pass() : ValidationResult.fail(errors);
return warnings.isEmpty() ? ValidationResult.pass() : ValidationResult.warn(warnings);
}
private boolean isTextLike(String path) {
@ -93,64 +87,5 @@ public class BasicPrePublishValidator implements PrePublishValidator {
|| value.chars().allMatch(ch -> ch == 'x' || ch == 'X' || ch == '*' || ch == '-');
}
private String extractMatchedValue(String line, Matcher matcher, SecretRule rule) {
if (rule.valueGroup() > 0) {
return matcher.group(rule.valueGroup());
}
Matcher assignmentMatcher = ASSIGNMENT_WITH_SENSITIVE_KEY.matcher(line);
if (!assignmentMatcher.find()) {
return null;
}
String rawValue = assignmentMatcher.group(2).trim();
if (rawValue.isBlank()) {
return null;
}
Matcher quotedLiteralMatcher = QUOTED_LITERAL.matcher(rawValue);
if (quotedLiteralMatcher.matches()) {
return quotedLiteralMatcher.group(2);
}
rawValue = stripInlineComment(rawValue);
if (rawValue.isBlank()) {
return null;
}
quotedLiteralMatcher = QUOTED_LITERAL.matcher(rawValue);
if (quotedLiteralMatcher.matches()) {
return quotedLiteralMatcher.group(2);
}
if (looksLikeExpression(rawValue) || IDENTIFIER.matcher(rawValue).matches()) {
return null;
}
return BARE_LITERAL.matcher(rawValue).matches() ? rawValue : null;
}
private String stripInlineComment(String rawValue) {
int hashIndex = rawValue.indexOf('#');
if (hashIndex >= 0) {
return rawValue.substring(0, hashIndex).trim();
}
return rawValue;
}
private boolean looksLikeExpression(String rawValue) {
return rawValue.contains("(")
|| rawValue.contains(")")
|| rawValue.contains(".")
|| rawValue.contains("[")
|| rawValue.contains("]")
|| rawValue.contains("{")
|| rawValue.contains("}")
|| rawValue.contains(",")
|| rawValue.contains(" ")
|| rawValue.contains("+")
|| rawValue.contains("/");
}
private record SecretRule(Pattern pattern, int valueGroup, String label) {}
}

View file

@ -25,7 +25,7 @@ public final class SkillPackagePolicy {
// Configuration and schemas
".toml", ".xml", ".xsd", ".xsl", ".dtd", ".ini", ".cfg", ".env",
// Scripts and source code
".js", ".ts", ".py", ".sh", ".rb", ".go", ".rs", ".java", ".kt",
".js", ".cjs", ".mjs", ".ts", ".py", ".sh", ".rb", ".go", ".rs", ".java", ".kt",
".lua", ".sql", ".r", ".bat", ".ps1", ".zsh", ".bash",
// Images
".png", ".jpg", ".jpeg", ".svg", ".gif", ".webp", ".ico",
@ -126,7 +126,8 @@ public final class SkillPackagePolicy {
private static boolean isTextExtension(String path) {
return path.endsWith(".md") || path.endsWith(".txt")
|| path.endsWith(".json") || path.endsWith(".yaml") || path.endsWith(".yml")
|| path.endsWith(".js") || path.endsWith(".ts") || path.endsWith(".py") || path.endsWith(".sh")
|| path.endsWith(".js") || path.endsWith(".cjs") || path.endsWith(".mjs")
|| path.endsWith(".ts") || path.endsWith(".py") || path.endsWith(".sh")
|| path.endsWith(".html") || path.endsWith(".css") || path.endsWith(".csv")
|| path.endsWith(".toml") || path.endsWith(".xml") || path.endsWith(".xsd")
|| path.endsWith(".xsl") || path.endsWith(".dtd") || path.endsWith(".ini")

View file

@ -49,6 +49,7 @@ public class SkillPackageValidator {
public ValidationResult validate(List<PackageEntry> entries) {
List<String> errors = new ArrayList<>();
List<String> warnings = new ArrayList<>();
Set<String> normalizedPaths = new HashSet<>();
PackageEntry skillMd = null;
@ -66,12 +67,12 @@ public class SkillPackageValidator {
}
if (!hasAllowedExtension(normalizedPath)) {
errors.add("Disallowed file extension: " + normalizedPath);
warnings.add("Disallowed file extension: " + normalizedPath);
}
String contentMismatch = SkillPackagePolicy.validateContentMatchesExtension(normalizedPath, entry.content());
if (contentMismatch != null) {
errors.add(contentMismatch);
warnings.add(contentMismatch);
}
if (SkillPackagePolicy.SKILL_MD_PATH.equals(normalizedPath) && skillMd == null) {
@ -82,7 +83,7 @@ public class SkillPackageValidator {
// 1. Check SKILL.md exists at root
if (skillMd == null) {
errors.add("Missing required file: SKILL.md at root");
return ValidationResult.fail(errors);
return ValidationResult.of(errors, warnings);
}
// 2. Validate frontmatter
@ -111,7 +112,7 @@ public class SkillPackageValidator {
errors.add("Package too large: " + totalSize + " bytes (max: " + maxTotalPackageSize + ")");
}
return errors.isEmpty() ? ValidationResult.pass() : ValidationResult.fail(errors);
return ValidationResult.of(errors, warnings);
}
private boolean hasAllowedExtension(String normalizedPath) {

View file

@ -4,17 +4,32 @@ import java.util.List;
public record ValidationResult(
boolean passed,
List<String> errors
List<String> errors,
List<String> warnings
) {
public static ValidationResult pass() {
return new ValidationResult(true, List.of());
return new ValidationResult(true, List.of(), List.of());
}
public static ValidationResult fail(List<String> errors) {
return new ValidationResult(false, errors);
return new ValidationResult(false, List.copyOf(errors), List.of());
}
public static ValidationResult fail(String error) {
return new ValidationResult(false, List.of(error));
return new ValidationResult(false, List.of(error), List.of());
}
public static ValidationResult warn(List<String> warnings) {
return new ValidationResult(true, List.of(), List.copyOf(warnings));
}
public static ValidationResult of(List<String> errors, List<String> warnings) {
List<String> safeErrors = errors == null ? List.of() : List.copyOf(errors);
List<String> safeWarnings = warnings == null ? List.of() : List.copyOf(warnings);
return new ValidationResult(safeErrors.isEmpty(), safeErrors, safeWarnings);
}
public boolean hasWarnings() {
return !warnings.isEmpty();
}
}

View file

@ -26,13 +26,37 @@ class ReviewPermissionCheckerTest {
}
@Test
void skillAdminCannotReviewOwnSubmission() {
void skillAdminCannotReviewOwnSubmissionWithoutNamespaceRole() {
String userId = "user-1";
ReviewTask task = new ReviewTask(1L, 10L, userId);
assertFalse(checker.canReview(task, userId,
NamespaceType.TEAM, Map.of(), Set.of("SKILL_ADMIN")));
}
@Test
void skillAdminNamespaceAdminCanReviewOwnSubmission() {
String userId = "user-1";
ReviewTask task = new ReviewTask(1L, 10L, userId);
assertTrue(checker.canReview(task, userId,
NamespaceType.TEAM, Map.of(10L, NamespaceRole.ADMIN), Set.of("SKILL_ADMIN")));
}
@Test
void skillAdminNamespaceOwnerCanReviewOwnSubmission() {
String userId = "user-1";
ReviewTask task = new ReviewTask(1L, 10L, userId);
assertTrue(checker.canReview(task, userId,
NamespaceType.TEAM, Map.of(10L, NamespaceRole.OWNER), Set.of("SKILL_ADMIN")));
}
@Test
void skillAdminNamespaceMemberCannotReviewOwnSubmission() {
String userId = "user-1";
ReviewTask task = new ReviewTask(1L, 10L, userId);
assertFalse(checker.canReview(task, userId,
NamespaceType.TEAM, Map.of(10L, NamespaceRole.MEMBER), Set.of("SKILL_ADMIN")));
}
@Test
void superAdminCannotReviewOwnSubmission() {
String userId = "user-1";
@ -57,6 +81,24 @@ class ReviewPermissionCheckerTest {
NamespaceType.GLOBAL, Map.of(), Set.of("SUPER_ADMIN")));
}
@Test
void teamAdminCanReviewOwnTeamSubmission() {
String userId = "user-1";
ReviewTask task = new ReviewTask(1L, 10L, userId);
assertTrue(checker.canReview(task, userId,
NamespaceType.TEAM,
Map.of(10L, NamespaceRole.ADMIN), Set.of()));
}
@Test
void teamOwnerCanReviewOwnTeamSubmission() {
String userId = "user-1";
ReviewTask task = new ReviewTask(1L, 10L, userId);
assertTrue(checker.canReview(task, userId,
NamespaceType.TEAM,
Map.of(10L, NamespaceRole.OWNER), Set.of()));
}
@Test
void teamAdminCanReviewTeamSkill() {
ReviewTask task = new ReviewTask(1L, 10L, "user-2");

View file

@ -483,6 +483,46 @@ class ReviewServiceTest {
assertEquals(USER_ID, skill.getUpdatedBy());
}
@Test
void namespaceAdminCanApproveOwnSubmission() {
ReviewTask task = createPendingReviewTask();
Namespace ns = createTeamNamespace();
SkillVersion sv = createPendingReviewSkillVersion();
Skill skill = createSkill();
when(reviewTaskRepository.findById(REVIEW_TASK_ID)).thenReturn(Optional.of(task));
when(namespaceRepository.findById(NAMESPACE_ID)).thenReturn(Optional.of(ns));
when(permissionChecker.canReview(
eq(task),
eq(USER_ID),
eq(ns.getType()),
eq(Map.of(NAMESPACE_ID, NamespaceRole.ADMIN)),
eq(Set.of())))
.thenReturn(true);
when(reviewTaskRepository.updateStatusWithVersion(
REVIEW_TASK_ID,
ReviewTaskStatus.APPROVED,
USER_ID,
"self approved as namespace admin",
task.getVersion()))
.thenReturn(1);
when(skillVersionRepository.findById(SKILL_VERSION_ID)).thenReturn(Optional.of(sv));
when(skillRepository.findById(SKILL_ID)).thenReturn(Optional.of(skill));
when(skillRepository.findByNamespaceIdAndSlug(NAMESPACE_ID, "my-skill")).thenReturn(List.of(skill));
when(reviewTaskRepository.findById(REVIEW_TASK_ID)).thenReturn(Optional.of(task));
ReviewTask result = reviewService.approveReview(
REVIEW_TASK_ID,
USER_ID,
"self approved as namespace admin",
Map.of(NAMESPACE_ID, NamespaceRole.ADMIN),
Set.of());
assertNotNull(result);
assertEquals(SkillVersionStatus.PUBLISHED, sv.getStatus());
assertEquals(USER_ID, skill.getUpdatedBy());
}
@Test
void shouldThrowOnConcurrentModification() {
ReviewTask task = createPendingReviewTask();
@ -602,6 +642,43 @@ class ReviewServiceTest {
assertEquals(SkillVersionStatus.REJECTED, sv.getStatus());
}
@Test
void namespaceAdminCanRejectOwnSubmission() {
ReviewTask task = createPendingReviewTask();
Namespace ns = createTeamNamespace();
SkillVersion sv = createPendingReviewSkillVersion();
when(reviewTaskRepository.findById(REVIEW_TASK_ID)).thenReturn(Optional.of(task));
when(namespaceRepository.findById(NAMESPACE_ID)).thenReturn(Optional.of(ns));
when(permissionChecker.canReview(
eq(task),
eq(USER_ID),
eq(ns.getType()),
eq(Map.of(NAMESPACE_ID, NamespaceRole.ADMIN)),
eq(Set.of())))
.thenReturn(true);
when(reviewTaskRepository.updateStatusWithVersion(
REVIEW_TASK_ID,
ReviewTaskStatus.REJECTED,
USER_ID,
"self rejected as namespace admin",
task.getVersion()))
.thenReturn(1);
when(skillVersionRepository.findById(SKILL_VERSION_ID)).thenReturn(Optional.of(sv));
when(skillRepository.findById(SKILL_ID)).thenReturn(Optional.of(createSkill()));
when(reviewTaskRepository.findById(REVIEW_TASK_ID)).thenReturn(Optional.of(task));
ReviewTask result = reviewService.rejectReview(
REVIEW_TASK_ID,
USER_ID,
"self rejected as namespace admin",
Map.of(NAMESPACE_ID, NamespaceRole.ADMIN),
Set.of());
assertNotNull(result);
assertEquals(SkillVersionStatus.REJECTED, sv.getStatus());
}
@Test
void shouldThrowOnConcurrentModification() {
ReviewTask task = createPendingReviewTask();

View file

@ -5,6 +5,7 @@ import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.util.Map;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.*;
@ -158,4 +159,34 @@ class VisibilityCheckerTest {
boolean canAccess = checker.canAccess(hiddenPublicSkill, ADMIN_USER_ID, roles);
assertTrue(canAccess);
}
@Test
void testSuperAdminCanAccessPrivateSkill() {
boolean canAccess = checker.canAccess(privateSkill, OTHER_USER_ID, Map.of(), Set.of("SUPER_ADMIN"));
assertTrue(canAccess);
}
@Test
void testSuperAdminCanAccessHiddenSkill() {
boolean canAccess = checker.canAccess(hiddenPublicSkill, OTHER_USER_ID, Map.of(), Set.of("SUPER_ADMIN"));
assertTrue(canAccess);
}
@Test
void testSuperAdminCanAccessUnpublishedSkill() {
boolean canAccess = checker.canAccess(unpublishedPublicSkill, OTHER_USER_ID, Map.of(), Set.of("SUPER_ADMIN"));
assertTrue(canAccess);
}
@Test
void testNonSuperAdminPlatformRolesDoNotGrantAccess() {
boolean canAccess = checker.canAccess(privateSkill, OTHER_USER_ID, Map.of(), Set.of("REVIEWER"));
assertFalse(canAccess);
}
@Test
void testEmptyPlatformRolesDoNotGrantAccess() {
boolean canAccess = checker.canAccess(privateSkill, OTHER_USER_ID, Map.of(), Set.of());
assertFalse(canAccess);
}
}

View file

@ -156,7 +156,7 @@ class SkillGovernanceServiceTest {
}
@Test
void withdrawPendingVersion_demotesVersionToDraft() {
void withdrawPendingVersion_demotesVersionToUploaded() {
Skill skill = new Skill(1L, "demo", "owner", com.iflytek.skillhub.domain.skill.SkillVisibility.PUBLIC);
setField(skill, "id", 1L);
SkillVersion version = new SkillVersion(1L, "1.0.0", "owner");
@ -167,7 +167,7 @@ class SkillGovernanceServiceTest {
SkillVersion result = service.withdrawPendingVersion(skill, version, "owner");
assertThat(result.getStatus()).isEqualTo(SkillVersionStatus.DRAFT);
assertThat(result.getStatus()).isEqualTo(SkillVersionStatus.UPLOADED);
verify(skillVersionRepository).save(version);
verify(skillRepository).save(skill);
verify(objectStorageService, never()).deleteObject(any());

View file

@ -176,6 +176,89 @@ class SkillPublishServiceTest {
assertEquals(1L, submittedEvent.namespaceId());
}
@Test
void testPublishFromEntries_ShouldRequireConfirmationWhenWarningsExist() throws Exception {
String namespaceSlug = "test-ns";
String publisherId = "user-100";
String skillMdContent = "---\nname: test-skill\ndescription: Test\nversion: 1.0.0\n---\nBody";
PackageEntry skillMd = new PackageEntry("SKILL.md", skillMdContent.getBytes(), skillMdContent.length(), "text/markdown");
List<PackageEntry> entries = List.of(skillMd);
Namespace namespace = new Namespace(namespaceSlug, "Test NS", "user-1");
setId(namespace, 1L);
NamespaceMember member = mock(NamespaceMember.class);
SkillMetadata metadata = new SkillMetadata("test-skill", "Test", "1.0.0", "Body", Map.of());
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
when(namespaceMemberRepository.findByNamespaceIdAndUserId(any(), eq(publisherId))).thenReturn(Optional.of(member));
when(skillPackageValidator.validate(entries)).thenReturn(ValidationResult.warn(List.of("Disallowed file extension: malware.exe")));
when(skillMetadataParser.parse(skillMdContent)).thenReturn(metadata);
when(prePublishValidator.validate(any())).thenReturn(ValidationResult.warn(List.of(
"SKILL.md line 5 contains a value that looks like a secret or token.")));
DomainBadRequestException exception = assertThrows(DomainBadRequestException.class, () -> service.publishFromEntries(
namespaceSlug,
entries,
publisherId,
SkillVisibility.PUBLIC,
Set.of()
));
assertEquals("error.skill.publish.precheck.confirmRequired", exception.messageCode());
assertTrue(String.valueOf(exception.messageArgs()[0]).contains("Disallowed file extension: malware.exe"));
assertTrue(String.valueOf(exception.messageArgs()[0]).contains("looks like a secret or token"));
verify(skillVersionRepository, never()).save(any(SkillVersion.class));
}
@Test
void testPublishFromEntries_ShouldAllowPublishAfterWarningConfirmation() throws Exception {
String namespaceSlug = "test-ns";
String publisherId = "user-100";
String skillMdContent = "---\nname: test-skill\ndescription: Test\nversion: 1.0.0\n---\nBody";
PackageEntry skillMd = new PackageEntry("SKILL.md", skillMdContent.getBytes(), skillMdContent.length(), "text/markdown");
List<PackageEntry> entries = List.of(skillMd);
Namespace namespace = new Namespace(namespaceSlug, "Test NS", "user-1");
setId(namespace, 1L);
NamespaceMember member = mock(NamespaceMember.class);
SkillMetadata metadata = new SkillMetadata("test-skill", "Test", "1.0.0", "Body", Map.of());
Skill skill = new Skill(1L, "test-skill", publisherId, SkillVisibility.PUBLIC);
setId(skill, 1L);
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
when(namespaceMemberRepository.findByNamespaceIdAndUserId(any(), eq(publisherId))).thenReturn(Optional.of(member));
when(skillPackageValidator.validate(entries)).thenReturn(ValidationResult.warn(List.of("Disallowed file extension: malware.exe")));
when(skillMetadataParser.parse(skillMdContent)).thenReturn(metadata);
when(prePublishValidator.validate(any())).thenReturn(ValidationResult.warn(List.of(
"SKILL.md line 5 contains a value that looks like a secret or token.")));
when(skillRepository.findByNamespaceIdAndSlug(any(), eq("test-skill"))).thenReturn(List.of(skill));
when(skillRepository.findByNamespaceIdAndSlugAndOwnerId(any(), eq("test-skill"), eq(publisherId))).thenReturn(Optional.of(skill));
when(skillVersionRepository.findBySkillIdAndVersion(any(), eq("1.0.0"))).thenReturn(Optional.empty());
when(skillVersionRepository.save(any(SkillVersion.class))).thenAnswer(invocation -> {
SkillVersion saved = invocation.getArgument(0);
if (saved.getId() == null) {
setId(saved, 10L);
}
return saved;
});
when(skillRepository.save(any())).thenReturn(skill);
SkillPublishService.PublishResult result = service.publishFromEntries(
namespaceSlug,
entries,
publisherId,
SkillVisibility.PUBLIC,
Set.of(),
true
);
assertEquals("1.0.0", result.version().getVersion());
assertEquals(SkillVersionStatus.PENDING_REVIEW, result.version().getStatus());
verify(skillVersionRepository, atLeastOnce()).save(any(SkillVersion.class));
}
@Test
void testPublishFromEntries_ShouldReplaceDraftVersionWithSameVersion() throws Exception {
String namespaceSlug = "test-ns";
@ -713,7 +796,7 @@ class SkillPublishServiceTest {
}
@Test
void testRereleasePublishedVersion_ShouldCloneFilesAndAutoPublish() throws Exception {
void testRereleasePublishedVersion_ShouldCloneFilesAndSubmitForReview() throws Exception {
String publisherId = "user-100";
Skill skill = new Skill(1L, "demo-skill", publisherId, SkillVisibility.PUBLIC);
setId(skill, 11L);
@ -772,15 +855,16 @@ class SkillPublishServiceTest {
"1.2.3",
"1.2.4",
publisherId,
Map.of(skill.getNamespaceId(), com.iflytek.skillhub.domain.namespace.NamespaceRole.OWNER)
Map.of(skill.getNamespaceId(), com.iflytek.skillhub.domain.namespace.NamespaceRole.OWNER),
false
);
assertEquals("1.2.4", result.version().getVersion());
assertEquals(SkillVersionStatus.PUBLISHED, result.version().getStatus());
assertEquals(Instant.now(CLOCK), result.version().getPublishedAt());
assertEquals(30L, skill.getLatestVersionId());
verify(reviewTaskRepository, never()).save(any());
verify(eventPublisher).publishEvent(any(SkillPublishedEvent.class));
// Rerelease for PUBLIC skill should go to PENDING_REVIEW (respecting visibility rules)
assertEquals(SkillVersionStatus.PENDING_REVIEW, result.version().getStatus());
// Review task should be created for PUBLIC skill
verify(reviewTaskRepository).save(any());
verify(eventPublisher, never()).publishEvent(any(SkillPublishedEvent.class));
verify(skillPackageValidator).validate(argThat(entries ->
entries.size() == 2
&& entries.stream().anyMatch(entry ->
@ -809,10 +893,176 @@ class SkillPublishServiceTest {
"1.2.3",
"1.2.4",
publisherId,
Map.of(skill.getNamespaceId(), com.iflytek.skillhub.domain.namespace.NamespaceRole.OWNER)
Map.of(skill.getNamespaceId(), com.iflytek.skillhub.domain.namespace.NamespaceRole.OWNER),
false
));
}
@Test
void testRereleasePublishedVersion_PrivateSkill_ShouldGoToUploaded() throws Exception {
String publisherId = "user-100";
Skill skill = new Skill(1L, "demo-skill", publisherId, SkillVisibility.PRIVATE);
setId(skill, 11L);
skill.setDisplayName("Demo Skill");
skill.setSummary("Original summary");
Namespace namespace = new Namespace("global", "Global", "owner");
setId(namespace, 1L);
SkillVersion sourceVersion = new SkillVersion(skill.getId(), "1.2.3", publisherId);
setId(sourceVersion, 21L);
sourceVersion.setStatus(SkillVersionStatus.PUBLISHED);
sourceVersion.setPublishedAt(Instant.parse("2026-03-15T10:00:00Z"));
String sourceSkillMd = """
---
name: Demo Skill
description: Original summary
version: 1.2.3
---
Hello world
""";
SkillFile skillMdFile = new SkillFile(sourceVersion.getId(), "SKILL.md", (long) sourceSkillMd.getBytes(StandardCharsets.UTF_8).length, "text/markdown", "hash1", "skills/11/21/SKILL.md");
SkillMetadata rereleaseMetadata = new SkillMetadata(
"Demo Skill",
"Original summary",
"1.2.4",
"Hello world",
Map.of("name", "Demo Skill", "description", "Original summary", "version", "1.2.4"));
when(skillRepository.findById(skill.getId())).thenReturn(Optional.of(skill));
when(namespaceRepository.findById(skill.getNamespaceId())).thenReturn(Optional.of(namespace));
when(namespaceRepository.findBySlug("global")).thenReturn(Optional.of(namespace));
when(skillVersionRepository.findBySkillIdAndVersion(skill.getId(), "1.2.3")).thenReturn(Optional.of(sourceVersion));
when(skillVersionRepository.findBySkillIdAndVersion(skill.getId(), "1.2.4")).thenReturn(Optional.empty());
when(skillFileRepository.findByVersionId(sourceVersion.getId())).thenReturn(List.of(skillMdFile));
when(objectStorageService.getObject(skillMdFile.getStorageKey())).thenReturn(new java.io.ByteArrayInputStream(sourceSkillMd.getBytes(StandardCharsets.UTF_8)));
when(skillPackageValidator.validate(anyList())).thenReturn(ValidationResult.pass());
when(skillMetadataParser.parse(anyString())).thenReturn(rereleaseMetadata);
when(prePublishValidator.validate(any())).thenReturn(ValidationResult.pass());
when(skillVersionRepository.save(any(SkillVersion.class))).thenAnswer(invocation -> {
SkillVersion saved = invocation.getArgument(0);
if (saved.getId() == null) {
setId(saved, 30L);
}
return saved;
});
when(skillRepository.save(any())).thenReturn(skill);
SkillPublishService.PublishResult result = service.rereleasePublishedVersion(
skill.getId(),
"1.2.3",
"1.2.4",
publisherId,
Map.of(skill.getNamespaceId(), com.iflytek.skillhub.domain.namespace.NamespaceRole.OWNER),
false
);
assertEquals("1.2.4", result.version().getVersion());
// Rerelease for PRIVATE skill should go to UPLOADED status
assertEquals(SkillVersionStatus.UPLOADED, result.version().getStatus());
// No review task for PRIVATE skill
verify(reviewTaskRepository, never()).save(any());
verify(eventPublisher, never()).publishEvent(any(SkillPublishedEvent.class));
// latestVersionId should be updated for PRIVATE skill
assertEquals(30L, skill.getLatestVersionId());
}
@Test
void testRereleasePublishedVersion_ShouldRequireConfirmationWhenWarningsExist() throws Exception {
String publisherId = "user-100";
Skill skill = new Skill(1L, "demo-skill", publisherId, SkillVisibility.PUBLIC);
setId(skill, 11L);
skill.setDisplayName("Demo Skill");
skill.setSummary("Original summary");
Namespace namespace = new Namespace("global", "Global", "owner");
setId(namespace, 1L);
SkillVersion sourceVersion = new SkillVersion(skill.getId(), "1.2.3", publisherId);
setId(sourceVersion, 21L);
sourceVersion.setStatus(SkillVersionStatus.PUBLISHED);
sourceVersion.setPublishedAt(Instant.parse("2026-03-15T10:00:00Z"));
String sourceSkillMd = "---\nname: Demo Skill\ndescription: Original summary\nversion: 1.2.3\n---\nHello world";
SkillFile skillMdFile = new SkillFile(sourceVersion.getId(), "SKILL.md", (long) sourceSkillMd.getBytes(StandardCharsets.UTF_8).length, "text/markdown", "hash1", "skills/11/21/SKILL.md");
SkillMetadata rereleaseMetadata = new SkillMetadata(
"Demo Skill", "Original summary", "1.2.4", "Hello world",
Map.of("name", "Demo Skill", "description", "Original summary", "version", "1.2.4"));
when(skillRepository.findById(skill.getId())).thenReturn(Optional.of(skill));
when(namespaceRepository.findById(skill.getNamespaceId())).thenReturn(Optional.of(namespace));
when(namespaceRepository.findBySlug("global")).thenReturn(Optional.of(namespace));
when(skillVersionRepository.findBySkillIdAndVersion(skill.getId(), "1.2.3")).thenReturn(Optional.of(sourceVersion));
when(skillVersionRepository.findBySkillIdAndVersion(skill.getId(), "1.2.4")).thenReturn(Optional.empty());
when(skillFileRepository.findByVersionId(sourceVersion.getId())).thenReturn(List.of(skillMdFile));
when(objectStorageService.getObject(skillMdFile.getStorageKey())).thenReturn(new java.io.ByteArrayInputStream(sourceSkillMd.getBytes(StandardCharsets.UTF_8)));
when(skillPackageValidator.validate(anyList())).thenReturn(ValidationResult.pass());
when(skillMetadataParser.parse(anyString())).thenReturn(rereleaseMetadata);
when(prePublishValidator.validate(any())).thenReturn(ValidationResult.warn(List.of(
"SKILL.md line 5 contains a value that looks like a secret or token.")));
DomainBadRequestException exception = assertThrows(DomainBadRequestException.class, () -> service.rereleasePublishedVersion(
skill.getId(), "1.2.3", "1.2.4", publisherId,
Map.of(skill.getNamespaceId(), com.iflytek.skillhub.domain.namespace.NamespaceRole.OWNER),
false
));
assertEquals("error.skill.publish.precheck.confirmRequired", exception.messageCode());
assertTrue(String.valueOf(exception.messageArgs()[0]).contains("looks like a secret or token"));
verify(skillVersionRepository, never()).save(any(SkillVersion.class));
}
@Test
void testRereleasePublishedVersion_ShouldSucceedWhenWarningsConfirmed() throws Exception {
String publisherId = "user-100";
Skill skill = new Skill(1L, "demo-skill", publisherId, SkillVisibility.PUBLIC);
setId(skill, 11L);
skill.setDisplayName("Demo Skill");
skill.setSummary("Original summary");
Namespace namespace = new Namespace("global", "Global", "owner");
setId(namespace, 1L);
SkillVersion sourceVersion = new SkillVersion(skill.getId(), "1.2.3", publisherId);
setId(sourceVersion, 21L);
sourceVersion.setStatus(SkillVersionStatus.PUBLISHED);
sourceVersion.setPublishedAt(Instant.parse("2026-03-15T10:00:00Z"));
String sourceSkillMd = "---\nname: Demo Skill\ndescription: Original summary\nversion: 1.2.3\n---\nHello world";
SkillFile skillMdFile = new SkillFile(sourceVersion.getId(), "SKILL.md", (long) sourceSkillMd.getBytes(StandardCharsets.UTF_8).length, "text/markdown", "hash1", "skills/11/21/SKILL.md");
SkillMetadata rereleaseMetadata = new SkillMetadata(
"Demo Skill", "Original summary", "1.2.4", "Hello world",
Map.of("name", "Demo Skill", "description", "Original summary", "version", "1.2.4"));
when(skillRepository.findById(skill.getId())).thenReturn(Optional.of(skill));
when(namespaceRepository.findById(skill.getNamespaceId())).thenReturn(Optional.of(namespace));
when(namespaceRepository.findBySlug("global")).thenReturn(Optional.of(namespace));
when(skillVersionRepository.findBySkillIdAndVersion(skill.getId(), "1.2.3")).thenReturn(Optional.of(sourceVersion));
when(skillVersionRepository.findBySkillIdAndVersion(skill.getId(), "1.2.4")).thenReturn(Optional.empty());
when(skillFileRepository.findByVersionId(sourceVersion.getId())).thenReturn(List.of(skillMdFile));
when(objectStorageService.getObject(skillMdFile.getStorageKey())).thenReturn(new java.io.ByteArrayInputStream(sourceSkillMd.getBytes(StandardCharsets.UTF_8)));
when(skillPackageValidator.validate(anyList())).thenReturn(ValidationResult.pass());
when(skillMetadataParser.parse(anyString())).thenReturn(rereleaseMetadata);
when(prePublishValidator.validate(any())).thenReturn(ValidationResult.warn(List.of(
"SKILL.md line 5 contains a value that looks like a secret or token.")));
when(skillVersionRepository.save(any(SkillVersion.class))).thenAnswer(invocation -> {
SkillVersion saved = invocation.getArgument(0);
if (saved.getId() == null) { setId(saved, 30L); }
return saved;
});
when(skillRepository.save(any())).thenReturn(skill);
SkillPublishService.PublishResult result = service.rereleasePublishedVersion(
skill.getId(), "1.2.3", "1.2.4", publisherId,
Map.of(skill.getNamespaceId(), com.iflytek.skillhub.domain.namespace.NamespaceRole.OWNER),
true // confirmWarnings = true should bypass warning and succeed
);
assertEquals("1.2.4", result.version().getVersion());
assertEquals(SkillVersionStatus.PENDING_REVIEW, result.version().getStatus());
verify(skillVersionRepository, atLeastOnce()).save(any(SkillVersion.class));
}
@Test
void testPublishFromEntries_ShouldRejectWhenOtherOwnerHasPublishedSkill() throws Exception {
String namespaceSlug = "test-ns";
@ -846,6 +1096,39 @@ class SkillPublishServiceTest {
));
}
@Test
void testPublishFromEntries_ShouldRejectWithPrivateConflictWhenOtherOwnerHasPrivatePublishedSkill() throws Exception {
String namespaceSlug = "test-ns";
String publisherId = "user-200";
String skillMdContent = "---\nname: test-skill\ndescription: Test\nversion: 1.0.0\n---\nBody";
PackageEntry skillMd = new PackageEntry("SKILL.md", skillMdContent.getBytes(), skillMdContent.length(), "text/markdown");
List<PackageEntry> entries = List.of(skillMd);
Namespace namespace = new Namespace(namespaceSlug, "Test NS", "user-1");
setId(namespace, 1L);
NamespaceMember member = mock(NamespaceMember.class);
SkillMetadata metadata = new SkillMetadata("test-skill", "Test", "1.0.0", "Body", Map.of());
Skill existingSkill = new Skill(1L, "test-skill", "user-100", SkillVisibility.PRIVATE);
setId(existingSkill, 1L);
SkillVersion publishedVersion = new SkillVersion(1L, "0.1.0", "user-100");
publishedVersion.setStatus(SkillVersionStatus.PUBLISHED);
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
when(namespaceMemberRepository.findByNamespaceIdAndUserId(any(), eq(publisherId))).thenReturn(Optional.of(member));
when(skillPackageValidator.validate(entries)).thenReturn(ValidationResult.pass());
when(skillMetadataParser.parse(skillMdContent)).thenReturn(metadata);
when(prePublishValidator.validate(any())).thenReturn(ValidationResult.pass());
when(skillRepository.findByNamespaceIdAndSlug(any(), eq("test-skill"))).thenReturn(List.of(existingSkill));
when(skillVersionRepository.findBySkillIdAndStatus(1L, SkillVersionStatus.PUBLISHED)).thenReturn(List.of(publishedVersion));
DomainBadRequestException ex = assertThrows(DomainBadRequestException.class, () -> service.publishFromEntries(
namespaceSlug, entries, publisherId, SkillVisibility.PRIVATE, Set.of()
));
assertEquals("error.skill.publish.nameConflict.private", ex.messageCode());
}
@Test
void testPublishFromEntries_ShouldAllowWhenOtherOwnerHasNonPublishedSkill() throws Exception {
String namespaceSlug = "test-ns";
@ -934,8 +1217,8 @@ class SkillPublishServiceTest {
service.publishFromEntries(namespaceSlug, entries, publisherId, SkillVisibility.PUBLIC, Set.of());
// Verify pending version was withdrawn to DRAFT
assertEquals(SkillVersionStatus.DRAFT, pendingV1.getStatus());
// Verify pending version was withdrawn to UPLOADED (not DRAFT, so it remains visible)
assertEquals(SkillVersionStatus.UPLOADED, pendingV1.getStatus());
verify(reviewTaskRepository).delete(pendingTask);
verify(skillVersionRepository).save(pendingV1);
}

View file

@ -30,6 +30,7 @@ import java.lang.reflect.Field;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
@ -50,7 +51,6 @@ class SkillQueryServiceTest {
private SkillTagRepository skillTagRepository;
@Mock
private ObjectStorageService objectStorageService;
@Mock
private VisibilityChecker visibilityChecker;
@Mock
private PromotionRequestRepository promotionRequestRepository;
@ -65,6 +65,7 @@ class SkillQueryServiceTest {
@BeforeEach
void setUp() {
visibilityChecker = new VisibilityChecker();
skillSlugResolutionService = new SkillSlugResolutionService(skillRepository);
skillLifecycleProjectionService = new SkillLifecycleProjectionService(skillVersionRepository);
service = new SkillQueryService(
@ -105,7 +106,6 @@ class SkillQueryServiceTest {
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill));
when(visibilityChecker.canAccess(skill, userId, userNsRoles)).thenReturn(true);
when(skillVersionRepository.findById(10L)).thenReturn(Optional.of(version));
when(userAccountRepository.findById(userId)).thenReturn(Optional.of(new UserAccount(userId, "Alice", "alice@example.com", null)));
@ -148,7 +148,6 @@ class SkillQueryServiceTest {
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(publishedSkill, ownSkill));
when(visibilityChecker.canAccess(ownSkill, userId, userNsRoles)).thenReturn(true);
when(skillVersionRepository.findById(22L)).thenReturn(Optional.of(ownVersion));
SkillQueryService.SkillDetailDTO result = service.getSkillDetail(namespaceSlug, skillSlug, userId, userNsRoles);
@ -176,7 +175,6 @@ class SkillQueryServiceTest {
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill));
when(visibilityChecker.canAccess(skill, userId, userNsRoles)).thenReturn(false);
// Act & Assert
assertThrows(DomainForbiddenException.class, () ->
@ -215,13 +213,11 @@ class SkillQueryServiceTest {
setId(namespace, 1L);
Skill skill1 = new Skill(1L, "skill1", userId, SkillVisibility.PUBLIC);
setId(skill1, 1L);
Skill skill2 = new Skill(1L, "skill2", userId, SkillVisibility.PRIVATE);
Skill skill2 = new Skill(1L, "skill2", "user-200", SkillVisibility.PRIVATE);
setId(skill2, 2L);
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
when(skillRepository.findByNamespaceIdAndStatus(1L, SkillStatus.ACTIVE)).thenReturn(List.of(skill1, skill2));
when(visibilityChecker.canAccess(skill1, userId, userNsRoles)).thenReturn(true);
when(visibilityChecker.canAccess(skill2, userId, userNsRoles)).thenReturn(false);
// Act
Page<Skill> result = service.listSkillsByNamespace(namespaceSlug, userId, userNsRoles, pageable);
@ -267,8 +263,6 @@ class SkillQueryServiceTest {
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
when(skillRepository.findByNamespaceIdAndStatus(1L, SkillStatus.ACTIVE))
.thenReturn(List.of(ownUnpublishedSkill, othersUnpublishedSkill));
when(visibilityChecker.canAccess(ownUnpublishedSkill, userId, userNsRoles)).thenReturn(true);
when(visibilityChecker.canAccess(othersUnpublishedSkill, userId, userNsRoles)).thenReturn(false);
Page<Skill> result = service.listSkillsByNamespace(namespaceSlug, userId, userNsRoles, pageable);
@ -296,8 +290,6 @@ class SkillQueryServiceTest {
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
when(skillRepository.findByNamespaceIdAndStatus(1L, SkillStatus.ACTIVE))
.thenReturn(List.of(visibleSkill, hiddenSkill));
when(visibilityChecker.canAccess(visibleSkill, userId, userNsRoles)).thenReturn(true);
when(visibilityChecker.canAccess(hiddenSkill, userId, userNsRoles)).thenReturn(false);
Page<Skill> result = service.listSkillsByNamespace(namespaceSlug, userId, userNsRoles, pageable);
@ -325,7 +317,6 @@ class SkillQueryServiceTest {
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill));
when(visibilityChecker.canAccess(skill, "user-100", userNsRoles)).thenReturn(true);
when(skillVersionRepository.findBySkillIdAndVersion(1L, version)).thenReturn(Optional.of(skillVersion));
when(skillFileRepository.findByVersionId(1L)).thenReturn(List.of(file1));
when(objectStorageService.exists("key1")).thenReturn(true);
@ -358,7 +349,6 @@ class SkillQueryServiceTest {
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill));
when(visibilityChecker.canAccess(skill, callerId, userNsRoles)).thenReturn(true);
when(skillVersionRepository.findBySkillIdAndVersion(1L, version)).thenReturn(Optional.of(skillVersion));
assertThrows(DomainBadRequestException.class, () ->
@ -385,7 +375,6 @@ class SkillQueryServiceTest {
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill));
when(visibilityChecker.canAccess(skill, "user-100", userNsRoles)).thenReturn(true);
when(skillVersionRepository.findBySkillIdAndVersion(1L, version)).thenReturn(Optional.of(skillVersion));
when(skillFileRepository.findByVersionId(1L)).thenReturn(List.of(file));
when(objectStorageService.exists(file.getStorageKey())).thenReturn(true);
@ -419,7 +408,6 @@ class SkillQueryServiceTest {
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill));
when(visibilityChecker.canAccess(skill, "user-100", userNsRoles)).thenReturn(true);
when(skillVersionRepository.findBySkillIdAndVersion(1L, version)).thenReturn(Optional.of(skillVersion));
when(skillFileRepository.findByVersionId(1L)).thenReturn(List.of(availableFile, missingFile));
when(objectStorageService.exists("skills/1/1/SKILL.md")).thenReturn(true);
@ -482,7 +470,6 @@ class SkillQueryServiceTest {
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill));
when(visibilityChecker.canAccess(skill, "user-100", userNsRoles)).thenReturn(true);
when(skillVersionRepository.findBySkillIdAndVersion(1L, version)).thenReturn(Optional.of(skillVersion));
SkillQueryService.SkillVersionDetailDTO result = service.getVersionDetail(
@ -516,7 +503,6 @@ class SkillQueryServiceTest {
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill));
when(visibilityChecker.canAccess(skill, "user-100", userNsRoles)).thenReturn(true);
when(skillVersionRepository.findById(11L)).thenReturn(Optional.of(latestVersion));
when(skillFileRepository.findByVersionId(11L)).thenReturn(List.of(file));
when(objectStorageService.exists("storage-key")).thenReturn(true);
@ -555,7 +541,6 @@ class SkillQueryServiceTest {
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill));
when(visibilityChecker.canAccess(skill, ownerId, userNsRoles)).thenReturn(true);
when(skillVersionRepository.findBySkillId(1L)).thenReturn(List.of(pending, published, rejected));
Page<SkillVersion> result = service.listVersions(namespaceSlug, skillSlug, ownerId, userNsRoles, PageRequest.of(0, 20));
@ -589,7 +574,6 @@ class SkillQueryServiceTest {
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill));
when(visibilityChecker.canAccess(skill, "user-100", userNsRoles)).thenReturn(true);
when(skillVersionRepository.findBySkillIdAndStatus(1L, SkillVersionStatus.PUBLISHED))
.thenReturn(List.of(version100, version110));
when(skillVersionRepository.findById(10L)).thenReturn(Optional.of(version110));
@ -631,7 +615,6 @@ class SkillQueryServiceTest {
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill));
when(visibilityChecker.canAccess(skill, null, userNsRoles)).thenReturn(true);
when(skillVersionRepository.findById(11L)).thenReturn(Optional.of(version));
when(skillVersionRepository.findBySkillIdAndStatus(3L, SkillVersionStatus.PUBLISHED)).thenReturn(List.of(version));
when(skillFileRepository.findByVersionId(11L)).thenReturn(List.of(file));
@ -664,7 +647,6 @@ class SkillQueryServiceTest {
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill));
when(visibilityChecker.canAccess(skill, userId, userNsRoles)).thenReturn(true);
SkillQueryService.SkillDetailDTO result = service.getSkillDetail(namespaceSlug, skillSlug, userId, userNsRoles);
@ -691,7 +673,6 @@ class SkillQueryServiceTest {
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill));
when(visibilityChecker.canAccess(skill, userId, userNsRoles)).thenReturn(true);
when(skillVersionRepository.findById(11L)).thenReturn(Optional.of(published));
when(promotionRequestRepository.findBySourceSkillIdAndStatus(1L, ReviewTaskStatus.PENDING)).thenReturn(Optional.empty());
when(promotionRequestRepository.findBySourceSkillIdAndStatus(1L, ReviewTaskStatus.APPROVED)).thenReturn(Optional.empty());
@ -723,7 +704,6 @@ class SkillQueryServiceTest {
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill));
when(visibilityChecker.canAccess(skill, userId, userNsRoles)).thenReturn(true);
when(skillVersionRepository.findById(11L)).thenReturn(Optional.of(published));
when(promotionRequestRepository.findBySourceSkillIdAndStatus(1L, ReviewTaskStatus.PENDING))
.thenReturn(Optional.of(mock(com.iflytek.skillhub.domain.review.PromotionRequest.class)));
@ -753,7 +733,6 @@ class SkillQueryServiceTest {
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill));
when(visibilityChecker.canAccess(skill, userId, userNsRoles)).thenReturn(true);
when(skillVersionRepository.findById(11L)).thenReturn(Optional.of(published));
when(promotionRequestRepository.findBySourceSkillIdAndStatus(1L, ReviewTaskStatus.PENDING)).thenReturn(Optional.empty());
when(promotionRequestRepository.findBySourceSkillIdAndStatus(1L, ReviewTaskStatus.APPROVED))
@ -784,7 +763,6 @@ class SkillQueryServiceTest {
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill));
when(visibilityChecker.canAccess(skill, userId, userNsRoles)).thenReturn(true);
when(skillVersionRepository.findById(11L)).thenReturn(Optional.of(published));
SkillQueryService.SkillDetailDTO result = service.getSkillDetail(namespaceSlug, skillSlug, userId, userNsRoles);
@ -793,6 +771,56 @@ class SkillQueryServiceTest {
assertFalse(result.canSubmitPromotion());
}
@Test
void testGetSkillDetail_ShouldNotGrantLifecyclePermissionToSuperAdminInPortal() throws Exception {
String namespaceSlug = "test-ns";
String skillSlug = "test-skill";
String userId = "super-1";
Map<Long, NamespaceRole> userNsRoles = Map.of(1L, NamespaceRole.MEMBER);
Namespace namespace = new Namespace(namespaceSlug, "Test NS", "owner-1");
setId(namespace, 1L);
Skill skill = new Skill(1L, skillSlug, "owner-1", SkillVisibility.PUBLIC);
setId(skill, 1L);
skill.setStatus(SkillStatus.ACTIVE);
skill.setLatestVersionId(11L);
SkillVersion published = new SkillVersion(1L, "1.0.0", "owner-1");
setId(published, 11L);
published.setStatus(SkillVersionStatus.PUBLISHED);
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill));
when(skillVersionRepository.findById(11L)).thenReturn(Optional.of(published));
SkillQueryService.SkillDetailDTO result = service.getSkillDetail(
namespaceSlug, skillSlug, userId, userNsRoles, Set.of("SUPER_ADMIN"));
assertFalse(result.canManageLifecycle());
assertFalse(result.canSubmitPromotion());
assertEquals("PUBLISHED", result.resolutionMode());
}
@Test
void testGetSkillDetail_ShouldNotGrantPrivateVisibilityToSuperAdminInPortal() throws Exception {
String namespaceSlug = "test-ns";
String skillSlug = "test-skill";
String userId = "super-1";
Namespace namespace = new Namespace(namespaceSlug, "Test NS", "owner-1");
setId(namespace, 1L);
Skill skill = new Skill(1L, skillSlug, "owner-1", SkillVisibility.PRIVATE);
setId(skill, 1L);
skill.setStatus(SkillStatus.ACTIVE);
skill.setLatestVersionId(11L);
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill));
assertThrows(DomainForbiddenException.class, () ->
service.getSkillDetail(namespaceSlug, skillSlug, userId, Map.of(), Set.of("SUPER_ADMIN")));
}
@Test
void testGetSkillDetail_ShouldPreferPendingVersionForOwnerPreview() throws Exception {
String namespaceSlug = "test-ns";
@ -813,7 +841,6 @@ class SkillQueryServiceTest {
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill));
when(visibilityChecker.canAccess(skill, ownerId, userNsRoles)).thenReturn(true);
when(skillVersionRepository.findById(12L)).thenReturn(Optional.of(pending));
when(skillVersionRepository.findBySkillIdAndStatus(1L, SkillVersionStatus.PUBLISHED))
.thenReturn(List.of());
@ -853,7 +880,6 @@ class SkillQueryServiceTest {
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill));
when(visibilityChecker.canAccess(skill, ownerId, userNsRoles)).thenReturn(true);
when(skillVersionRepository.findById(11L)).thenReturn(Optional.of(published));
SkillQueryService.SkillDetailDTO result = service.getSkillDetail(namespaceSlug, skillSlug, ownerId, userNsRoles);
@ -889,7 +915,6 @@ class SkillQueryServiceTest {
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill));
when(visibilityChecker.canAccess(skill, ownerId, userNsRoles)).thenReturn(true);
when(skillVersionRepository.findById(12L)).thenReturn(Optional.of(rejected));
when(skillVersionRepository.findBySkillIdAndStatus(1L, SkillVersionStatus.PUBLISHED)).thenReturn(List.of());
when(skillVersionRepository.findBySkillId(1L)).thenReturn(List.of(rejected));
@ -925,7 +950,6 @@ class SkillQueryServiceTest {
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill));
when(visibilityChecker.canAccess(skill, ownerId, userNsRoles)).thenReturn(true);
when(skillVersionRepository.findBySkillIdAndVersion(1L, version)).thenReturn(Optional.of(pending));
SkillQueryService.SkillVersionDetailDTO result = service.getVersionDetail(
@ -960,7 +984,6 @@ class SkillQueryServiceTest {
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill));
when(visibilityChecker.canAccess(skill, ownerId, userNsRoles)).thenReturn(true);
when(skillVersionRepository.findBySkillIdAndVersion(1L, version)).thenReturn(Optional.of(pending));
when(skillFileRepository.findByVersionId(11L)).thenReturn(List.of(file));
when(objectStorageService.exists("storage-key")).thenReturn(true);
@ -992,7 +1015,6 @@ class SkillQueryServiceTest {
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill));
when(visibilityChecker.canAccess(skill, viewerId, userNsRoles)).thenReturn(true);
when(skillVersionRepository.findBySkillIdAndVersion(1L, version)).thenReturn(Optional.of(pending));
assertThrows(DomainBadRequestException.class, () ->
@ -1024,7 +1046,6 @@ class SkillQueryServiceTest {
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill));
when(visibilityChecker.canAccess(skill, userId, userNsRoles)).thenReturn(true);
when(skillVersionRepository.findBySkillId(1L)).thenReturn(List.of(rejected, draft, published));
Page<SkillVersion> result = service.listVersions(
@ -1059,7 +1080,6 @@ class SkillQueryServiceTest {
when(namespaceRepository.findBySlug(namespaceSlug)).thenReturn(Optional.of(namespace));
when(skillRepository.findByNamespaceIdAndSlug(1L, skillSlug)).thenReturn(List.of(skill));
when(visibilityChecker.canAccess(skill, userId, userNsRoles)).thenReturn(true);
when(skillVersionRepository.findBySkillIdAndStatus(1L, SkillVersionStatus.PUBLISHED)).thenReturn(List.of(published));
Page<SkillVersion> result = service.listVersions(

View file

@ -0,0 +1,272 @@
package com.iflytek.skillhub.domain.skill.service;
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
import com.iflytek.skillhub.domain.review.ReviewTask;
import com.iflytek.skillhub.domain.review.ReviewTaskRepository;
import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException;
import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException;
import com.iflytek.skillhub.domain.skill.*;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.context.ApplicationEventPublisher;
import java.time.Clock;
import java.util.Map;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
/**
* Unit tests for {@link SkillReviewSubmitService}.
*/
@ExtendWith(MockitoExtension.class)
class SkillReviewSubmitServiceTest {
@Mock
private SkillRepository skillRepository;
@Mock
private SkillVersionRepository skillVersionRepository;
@Mock
private ReviewTaskRepository reviewTaskRepository;
@Mock
private ApplicationEventPublisher eventPublisher;
private SkillReviewSubmitService service;
@BeforeEach
void setUp() {
service = new SkillReviewSubmitService(
skillRepository,
skillVersionRepository,
reviewTaskRepository,
null, // namespaceMemberRepository not used in these tests
eventPublisher,
Clock.systemUTC()
);
}
@Nested
@DisplayName("submitForReview")
class SubmitForReviewTests {
@Test
@DisplayName("should transition UPLOADED version to PENDING_REVIEW")
void shouldTransitionToPendingReview() {
// Given
Long skillId = 1L;
Long versionId = 100L;
String userId = "user-1";
Long namespaceId = 10L;
Skill skill = createSkill(skillId, userId, namespaceId, SkillVisibility.PRIVATE);
SkillVersion version = createVersion(versionId, skillId, SkillVersionStatus.UPLOADED);
when(skillRepository.findById(skillId)).thenReturn(Optional.of(skill));
when(skillVersionRepository.findById(versionId)).thenReturn(Optional.of(version));
when(reviewTaskRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
Map<Long, NamespaceRole> roles = Map.of();
// When
service.submitForReview(skillId, versionId, SkillVisibility.PUBLIC, userId, roles);
// Then
assertEquals(SkillVersionStatus.PENDING_REVIEW, version.getStatus());
assertEquals(SkillVisibility.PUBLIC, version.getRequestedVisibility());
verify(reviewTaskRepository).save(any(ReviewTask.class));
}
@Test
@DisplayName("should accept DRAFT version (legacy compatibility)")
void shouldAcceptDraftForLegacyCompatibility() {
// Given
Long skillId = 1L;
Long versionId = 100L;
String userId = "user-1";
Long namespaceId = 10L;
Skill skill = createSkill(skillId, userId, namespaceId, SkillVisibility.PRIVATE);
SkillVersion version = createVersion(versionId, skillId, SkillVersionStatus.DRAFT);
when(skillRepository.findById(skillId)).thenReturn(Optional.of(skill));
when(skillVersionRepository.findById(versionId)).thenReturn(Optional.of(version));
when(reviewTaskRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
Map<Long, NamespaceRole> roles = Map.of();
// When
service.submitForReview(skillId, versionId, SkillVisibility.PUBLIC, userId, roles);
// Then
assertEquals(SkillVersionStatus.PENDING_REVIEW, version.getStatus());
assertEquals(SkillVisibility.PUBLIC, version.getRequestedVisibility());
verify(reviewTaskRepository).save(any(ReviewTask.class));
}
@Test
@DisplayName("should reject when version is neither UPLOADED nor DRAFT")
void shouldRejectWhenNotUploadedOrDraft() {
// Given
Long skillId = 1L;
Long versionId = 100L;
String userId = "user-1";
Skill skill = createSkill(skillId, userId, 10L, SkillVisibility.PRIVATE);
SkillVersion version = createVersion(versionId, skillId, SkillVersionStatus.PUBLISHED);
when(skillRepository.findById(skillId)).thenReturn(Optional.of(skill));
when(skillVersionRepository.findById(versionId)).thenReturn(Optional.of(version));
// When/Then
assertThrows(DomainBadRequestException.class,
() -> service.submitForReview(skillId, versionId, SkillVisibility.PUBLIC, userId, Map.of()));
}
@Test
@DisplayName("should reject when user is not owner")
void shouldRejectWhenNotOwner() {
// Given
Long skillId = 1L;
Long versionId = 100L;
String ownerId = "owner-1";
String otherUserId = "other-user";
Skill skill = createSkill(skillId, ownerId, 10L, SkillVisibility.PRIVATE);
SkillVersion version = createVersion(versionId, skillId, SkillVersionStatus.UPLOADED);
when(skillRepository.findById(skillId)).thenReturn(Optional.of(skill));
when(skillVersionRepository.findById(versionId)).thenReturn(Optional.of(version));
// When/Then
assertThrows(DomainForbiddenException.class,
() -> service.submitForReview(skillId, versionId, SkillVisibility.PUBLIC, otherUserId, Map.of()));
}
}
@Nested
@DisplayName("confirmPublish")
class ConfirmPublishTests {
@Test
@DisplayName("should transition UPLOADED version to PUBLISHED for PRIVATE skill")
void shouldTransitionToPublished() {
// Given
Long skillId = 1L;
Long versionId = 100L;
String userId = "user-1";
Long namespaceId = 10L;
Skill skill = createSkill(skillId, userId, namespaceId, SkillVisibility.PRIVATE);
SkillVersion version = createVersion(versionId, skillId, SkillVersionStatus.UPLOADED);
when(skillRepository.findById(skillId)).thenReturn(Optional.of(skill));
when(skillVersionRepository.findById(versionId)).thenReturn(Optional.of(version));
// When
service.confirmPublish(skillId, versionId, userId, Map.of());
// Then
assertEquals(SkillVersionStatus.PUBLISHED, version.getStatus());
assertNotNull(version.getPublishedAt());
assertEquals(versionId, skill.getLatestVersionId());
verify(skillRepository).save(skill);
}
@Test
@DisplayName("should transition DRAFT version to PUBLISHED for PRIVATE skill (legacy compatibility)")
void shouldTransitionDraftToPublished() {
// Given
Long skillId = 1L;
Long versionId = 100L;
String userId = "user-1";
Long namespaceId = 10L;
Skill skill = createSkill(skillId, userId, namespaceId, SkillVisibility.PRIVATE);
SkillVersion version = createVersion(versionId, skillId, SkillVersionStatus.DRAFT);
when(skillRepository.findById(skillId)).thenReturn(Optional.of(skill));
when(skillVersionRepository.findById(versionId)).thenReturn(Optional.of(version));
// When
service.confirmPublish(skillId, versionId, userId, Map.of());
// Then
assertEquals(SkillVersionStatus.PUBLISHED, version.getStatus());
assertNotNull(version.getPublishedAt());
assertEquals(versionId, skill.getLatestVersionId());
verify(skillRepository).save(skill);
}
@Test
@DisplayName("should reject when skill is not PRIVATE")
void shouldRejectWhenNotPrivate() {
// Given
Long skillId = 1L;
Long versionId = 100L;
String userId = "user-1";
Skill skill = createSkill(skillId, userId, 10L, SkillVisibility.PUBLIC);
SkillVersion version = createVersion(versionId, skillId, SkillVersionStatus.UPLOADED);
when(skillRepository.findById(skillId)).thenReturn(Optional.of(skill));
when(skillVersionRepository.findById(versionId)).thenReturn(Optional.of(version));
// When/Then
assertThrows(DomainBadRequestException.class,
() -> service.confirmPublish(skillId, versionId, userId, Map.of()));
}
@Test
@DisplayName("should reject when version is neither UPLOADED nor DRAFT")
void shouldRejectWhenNotUploadedOrDraft() {
// Given
Long skillId = 1L;
Long versionId = 100L;
String userId = "user-1";
Skill skill = createSkill(skillId, userId, 10L, SkillVisibility.PRIVATE);
SkillVersion version = createVersion(versionId, skillId, SkillVersionStatus.PUBLISHED);
when(skillRepository.findById(skillId)).thenReturn(Optional.of(skill));
when(skillVersionRepository.findById(versionId)).thenReturn(Optional.of(version));
// When/Then
assertThrows(DomainBadRequestException.class,
() -> service.confirmPublish(skillId, versionId, userId, Map.of()));
}
}
private Skill createSkill(Long id, String ownerId, Long namespaceId, SkillVisibility visibility) {
Skill skill = new Skill(namespaceId, "test-skill", ownerId, visibility);
setField(skill, "id", id);
return skill;
}
private SkillVersion createVersion(Long id, Long skillId, SkillVersionStatus status) {
SkillVersion version = new SkillVersion(skillId, "1.0.0", "user-1");
setField(version, "id", id);
version.setStatus(status);
return version;
}
private void setField(Object target, String fieldName, Object value) {
try {
java.lang.reflect.Field field = target.getClass().getDeclaredField(fieldName);
field.setAccessible(true);
field.set(target, value);
} catch (Exception e) {
throw new RuntimeException(e);
}
}
}

View file

@ -15,7 +15,7 @@ class BasicPrePublishValidatorTest {
private final BasicPrePublishValidator validator = new BasicPrePublishValidator();
@Test
void shouldRejectObviousCredentialLeakWithHelpfulLocation() {
void shouldWarnOnObviousCredentialLeakWithHelpfulLocation() {
PackageEntry skillMd = new PackageEntry(
"SKILL.md",
"""
@ -36,8 +36,8 @@ class BasicPrePublishValidatorTest {
1L
));
assertFalse(result.passed());
assertTrue(result.errors().stream().anyMatch(error ->
assertTrue(result.passed());
assertTrue(result.warnings().stream().anyMatch(error ->
error.contains("SKILL.md")
&& error.contains("line 5")
&& error.contains("looks like a")));
@ -98,71 +98,4 @@ class BasicPrePublishValidatorTest {
assertTrue(result.passed());
}
@Test
void shouldAllowFunctionCallAssignedToTokenVariable() {
PackageEntry script = new PackageEntry(
"scripts/f2e_mock.py",
"""
token = extract_group_token_value(response, group_choice.group_id)
if token:
return token
""".getBytes(StandardCharsets.UTF_8),
97,
"text/x-python"
);
ValidationResult result = validator.validate(new PrePublishValidator.SkillPackageContext(
List.of(script),
new SkillMetadata("Safe Skill", "desc", "1.0.0", "body", Map.of()),
"user-1",
1L
));
assertTrue(result.passed());
}
@Test
void shouldAllowIdentifierAssignedToSecretNamedVariable() {
PackageEntry envTemplate = new PackageEntry(
"config.env",
"""
token=generated_token_value
api_key=current_api_key
""".getBytes(StandardCharsets.UTF_8),
46,
"text/plain"
);
ValidationResult result = validator.validate(new PrePublishValidator.SkillPackageContext(
List.of(envTemplate),
new SkillMetadata("Safe Skill", "desc", "1.0.0", "body", Map.of()),
"user-1",
1L
));
assertTrue(result.passed());
}
@Test
void shouldRejectQuotedSecretWithTrailingComment() {
PackageEntry script = new PackageEntry(
"scripts/publish.py",
"""
token = "ghp_abcdefghijklmnopqrstuvwxyz1234" # do not commit real token
""".getBytes(StandardCharsets.UTF_8),
76,
"text/x-python"
);
ValidationResult result = validator.validate(new PrePublishValidator.SkillPackageContext(
List.of(script),
new SkillMetadata("Secret Skill", "desc", "1.0.0", "body", Map.of()),
"user-1",
1L
));
assertFalse(result.passed());
assertTrue(result.errors().stream().anyMatch(error -> error.contains("scripts/publish.py")));
}
}

View file

@ -70,8 +70,8 @@ class SkillPackageValidatorTest {
ValidationResult result = validator.validate(entries);
assertFalse(result.passed());
assertTrue(result.errors().stream().anyMatch(e -> e.contains("Disallowed file extension") && e.contains("malware.exe")));
assertTrue(result.passed());
assertTrue(result.warnings().stream().anyMatch(e -> e.contains("Disallowed file extension") && e.contains("malware.exe")));
}
@Test
@ -236,8 +236,8 @@ class SkillPackageValidatorTest {
ValidationResult result = validator.validate(entries);
assertFalse(result.passed());
assertTrue(result.errors().stream().anyMatch(e -> e.contains("File content does not match extension")));
assertTrue(result.passed());
assertTrue(result.warnings().stream().anyMatch(e -> e.contains("File content does not match extension")));
}
@Test
@ -258,8 +258,8 @@ class SkillPackageValidatorTest {
ValidationResult result = validator.validate(entries);
assertFalse(result.passed());
assertTrue(result.errors().stream().anyMatch(e -> e.contains("File content does not match extension")));
assertTrue(result.passed());
assertTrue(result.warnings().stream().anyMatch(e -> e.contains("File content does not match extension")));
}
@Test
@ -269,8 +269,8 @@ class SkillPackageValidatorTest {
new PackageEntry("photo.jpeg", new byte[]{0x00, 0x00}, 2, "image/jpeg")
);
ValidationResult result = validator.validate(entries);
assertFalse(result.passed());
assertTrue(result.errors().stream().anyMatch(e -> e.contains("photo.jpeg")));
assertTrue(result.passed());
assertTrue(result.warnings().stream().anyMatch(e -> e.contains("photo.jpeg")));
}
@Test

View file

@ -0,0 +1,19 @@
package com.iflytek.skillhub.infra.jpa;
import com.iflytek.skillhub.domain.auth.PasswordResetRequest;
import com.iflytek.skillhub.domain.auth.PasswordResetRequestRepository;
import java.time.Instant;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
/**
* JPA-backed repository for password-reset verification-code requests.
*/
public interface PasswordResetRequestJpaRepository
extends JpaRepository<PasswordResetRequest, Long>, PasswordResetRequestRepository {
List<PasswordResetRequest> findByUserIdAndConsumedAtIsNullAndExpiresAtAfterOrderByCreatedAtDesc(
String userId,
Instant now
);
}

View file

@ -32,8 +32,10 @@ import java.util.List;
public class S3StorageService implements ObjectStorageService {
private static final Logger log = LoggerFactory.getLogger(S3StorageService.class);
private final S3StorageProperties properties;
private final Object bucketPreparationLock = new Object();
private S3Client s3Client;
private S3Presigner s3Presigner;
private volatile boolean bucketPrepared;
public S3StorageService(S3StorageProperties properties) { this.properties = properties; }
@ -42,6 +44,13 @@ public class S3StorageService implements ObjectStorageService {
ApacheHttpClient.Builder httpClientBuilder = ApacheHttpClient.builder()
.maxConnections(properties.getMaxConnections())
.connectionAcquisitionTimeout(properties.getConnectionAcquisitionTimeout());
this.s3Client = buildS3Client(httpClientBuilder);
this.s3Presigner = buildPresigner();
log.info("Initialized S3 storage client for bucket '{}' (bucket verification is deferred until first storage access)",
properties.getBucket());
}
protected S3Client buildS3Client(ApacheHttpClient.Builder httpClientBuilder) {
var builder = S3Client.builder()
.region(Region.of(properties.getRegion()))
.credentialsProvider(StaticCredentialsProvider.create(
@ -54,9 +63,7 @@ public class S3StorageService implements ObjectStorageService {
if (properties.getEndpoint() != null && !properties.getEndpoint().isBlank()) {
builder.endpointOverride(URI.create(properties.getEndpoint()));
}
this.s3Client = builder.build();
this.s3Presigner = buildPresigner();
ensureBucketExists();
return builder.build();
}
S3Presigner buildPresigner() {
@ -75,20 +82,28 @@ public class S3StorageService implements ObjectStorageService {
return presignerBuilder.build();
}
private void ensureBucketExists() {
if (!properties.isAutoCreateBucket()) {
s3Client.headBucket(HeadBucketRequest.builder().bucket(properties.getBucket()).build());
private void ensureBucketPrepared() {
if (!properties.isAutoCreateBucket() || bucketPrepared) {
return;
}
try { s3Client.headBucket(HeadBucketRequest.builder().bucket(properties.getBucket()).build()); }
catch (NoSuchBucketException e) {
log.info("Bucket '{}' does not exist, creating...", properties.getBucket());
s3Client.createBucket(CreateBucketRequest.builder().bucket(properties.getBucket()).build());
synchronized (bucketPreparationLock) {
if (bucketPrepared) {
return;
}
try {
s3Client.headBucket(HeadBucketRequest.builder().bucket(properties.getBucket()).build());
} catch (NoSuchBucketException e) {
log.info("Bucket '{}' does not exist, creating...", properties.getBucket());
s3Client.createBucket(CreateBucketRequest.builder().bucket(properties.getBucket()).build());
}
bucketPrepared = true;
}
}
@Override public void putObject(String key, InputStream data, long size, String contentType) {
try {
ensureBucketPrepared();
s3Client.putObject(PutObjectRequest.builder().bucket(properties.getBucket()).key(key).contentType(contentType).contentLength(size).build(), RequestBody.fromInputStream(data, size));
} catch (RuntimeException e) {
throw new StorageAccessException("putObject", key, e);
@ -97,6 +112,7 @@ public class S3StorageService implements ObjectStorageService {
@Override public InputStream getObject(String key) {
try {
ensureBucketPrepared();
return s3Client.getObject(GetObjectRequest.builder().bucket(properties.getBucket()).key(key).build());
} catch (RuntimeException e) {
throw new StorageAccessException("getObject", key, e);
@ -105,6 +121,7 @@ public class S3StorageService implements ObjectStorageService {
@Override public void deleteObject(String key) {
try {
ensureBucketPrepared();
s3Client.deleteObject(DeleteObjectRequest.builder().bucket(properties.getBucket()).key(key).build());
} catch (RuntimeException e) {
throw new StorageAccessException("deleteObject", key, e);
@ -114,6 +131,7 @@ public class S3StorageService implements ObjectStorageService {
@Override public void deleteObjects(List<String> keys) {
if (keys.isEmpty()) return;
try {
ensureBucketPrepared();
List<ObjectIdentifier> ids = keys.stream().map(k -> ObjectIdentifier.builder().key(k).build()).toList();
s3Client.deleteObjects(DeleteObjectsRequest.builder().bucket(properties.getBucket()).delete(Delete.builder().objects(ids).build()).build());
} catch (RuntimeException e) {
@ -122,13 +140,18 @@ public class S3StorageService implements ObjectStorageService {
}
@Override public boolean exists(String key) {
try { s3Client.headObject(HeadObjectRequest.builder().bucket(properties.getBucket()).key(key).build()); return true; }
try {
ensureBucketPrepared();
s3Client.headObject(HeadObjectRequest.builder().bucket(properties.getBucket()).key(key).build());
return true;
}
catch (NoSuchKeyException e) { return false; }
catch (RuntimeException e) { throw new StorageAccessException("exists", key, e); }
}
@Override public ObjectMetadata getMetadata(String key) {
try {
ensureBucketPrepared();
HeadObjectResponse resp = s3Client.headObject(HeadObjectRequest.builder().bucket(properties.getBucket()).key(key).build());
return new ObjectMetadata(resp.contentLength(), resp.contentType(), resp.lastModified());
} catch (RuntimeException e) {

View file

@ -2,12 +2,32 @@ package com.iflytek.skillhub.storage;
import org.junit.jupiter.api.Test;
import software.amazon.awssdk.services.s3.model.GetObjectRequest;
import software.amazon.awssdk.core.sync.RequestBody;
import software.amazon.awssdk.http.apache.ApacheHttpClient;
import software.amazon.awssdk.services.s3.S3Client;
import software.amazon.awssdk.services.s3.model.CreateBucketRequest;
import software.amazon.awssdk.services.s3.model.CreateBucketResponse;
import software.amazon.awssdk.services.s3.model.HeadBucketRequest;
import software.amazon.awssdk.services.s3.model.NoSuchBucketException;
import software.amazon.awssdk.services.s3.model.PutObjectRequest;
import software.amazon.awssdk.services.s3.model.PutObjectResponse;
import software.amazon.awssdk.services.s3.presigner.S3Presigner;
import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest;
import java.io.ByteArrayInputStream;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
class S3StorageServiceTest {
@ -27,6 +47,63 @@ class S3StorageServiceTest {
assertThat(presignedUrl.getPath()).isEqualTo("/artifacts/package.tgz");
}
@Test
void initShouldNotProbeBucketWhenAutoCreateIsDisabled() {
S3Client client = mock(S3Client.class);
S3Presigner presigner = mock(S3Presigner.class);
TestableS3StorageService service = new TestableS3StorageService(properties(false), client, presigner);
service.init();
verifyNoInteractions(client);
}
@Test
void putObjectShouldSkipBucketProbeWhenAutoCreateIsDisabled() {
S3Client client = mock(S3Client.class);
S3Presigner presigner = mock(S3Presigner.class);
when(client.putObject(any(PutObjectRequest.class), any(RequestBody.class)))
.thenReturn(PutObjectResponse.builder().eTag("etag").build());
TestableS3StorageService service = new TestableS3StorageService(properties(false), client, presigner);
service.init();
byte[] content = "hello".getBytes(StandardCharsets.UTF_8);
service.putObject("packages/demo.zip", new ByteArrayInputStream(content), content.length, "application/zip");
verify(client, never()).headBucket(any(HeadBucketRequest.class));
verify(client, never()).createBucket(any(CreateBucketRequest.class));
verify(client).putObject(any(PutObjectRequest.class), any(RequestBody.class));
}
@Test
void putObjectShouldCreateBucketOnlyOnceWhenAutoCreateIsEnabled() {
S3Client client = mock(S3Client.class);
S3Presigner presigner = mock(S3Presigner.class);
doThrow(NoSuchBucketException.builder().message("missing").build())
.when(client).headBucket(any(HeadBucketRequest.class));
when(client.createBucket(any(CreateBucketRequest.class)))
.thenReturn(CreateBucketResponse.builder().build());
when(client.putObject(any(PutObjectRequest.class), any(RequestBody.class)))
.thenReturn(PutObjectResponse.builder().eTag("etag").build());
TestableS3StorageService service = new TestableS3StorageService(properties(true), client, presigner);
service.init();
byte[] content = "hello".getBytes(StandardCharsets.UTF_8);
service.putObject("packages/demo-1.zip", new ByteArrayInputStream(content), content.length, "application/zip");
service.putObject("packages/demo-2.zip", new ByteArrayInputStream(content), content.length, "application/zip");
verify(client, times(1)).headBucket(any(HeadBucketRequest.class));
verify(client, times(1)).createBucket(any(CreateBucketRequest.class));
verify(client, times(2)).putObject(any(PutObjectRequest.class), any(RequestBody.class));
}
private S3StorageProperties properties(boolean autoCreateBucket) {
S3StorageProperties properties = createProperties(true);
properties.setBucket("skillhub");
properties.setAutoCreateBucket(autoCreateBucket);
return properties;
}
private URI presignGetObjectUrl(boolean forcePathStyle) {
S3StorageService storageService = new S3StorageService(createProperties(forcePathStyle));
try (var presigner = storageService.buildPresigner()) {
@ -53,4 +130,25 @@ class S3StorageServiceTest {
properties.setForcePathStyle(forcePathStyle);
return properties;
}
private static final class TestableS3StorageService extends S3StorageService {
private final S3Client client;
private final S3Presigner presigner;
private TestableS3StorageService(S3StorageProperties properties, S3Client client, S3Presigner presigner) {
super(properties);
this.client = client;
this.presigner = presigner;
}
@Override
protected S3Client buildS3Client(ApacheHttpClient.Builder httpClientBuilder) {
return client;
}
@Override
S3Presigner buildPresigner() {
return presigner;
}
}
}

View file

@ -5,3 +5,12 @@ export async function setEnglishLocale(page: Page) {
window.localStorage.setItem('i18nextLng', 'en')
})
}
export async function setUniqueClientIp(page: Page, seed: string) {
const suffix = Date.now() + Math.floor(Math.random() * 1000)
const thirdOctet = seed.split('').reduce((sum, char) => sum + char.charCodeAt(0), 0) % 250
const fourthOctet = suffix % 250
await page.context().setExtraHTTPHeaders({
'X-Forwarded-For': `10.0.${thirdOctet}.${fourthOctet}`,
})
}

View file

@ -0,0 +1,64 @@
import type { Browser, Page, TestInfo } from '@playwright/test'
import { loginWithCredentials, registerSession } from './session'
import { E2eTestDataBuilder, type SeededReviewData } from './test-data-builder'
function getOptionalEnv(name: string): string | undefined {
const value = process.env[name]?.trim()
return value ? value : undefined
}
function adminCredentials() {
return {
username: getOptionalEnv('E2E_ADMIN_USERNAME') ?? getOptionalEnv('BOOTSTRAP_ADMIN_USERNAME') ?? 'admin',
password: getOptionalEnv('E2E_ADMIN_PASSWORD') ?? getOptionalEnv('BOOTSTRAP_ADMIN_PASSWORD') ?? 'ChangeMe!2026',
}
}
function matchCandidateUsername(
candidate: { userId: string; displayName: string; email?: string },
username: string,
) {
return candidate.userId === username
|| candidate.displayName === username
|| candidate.email === `${username}@example.test`
}
export async function createNamespaceReviewData(
browser: Browser,
page: Page,
testInfo: TestInfo,
): Promise<SeededReviewData & { reviewTaskId: number; cleanup: () => Promise<void> }> {
const credentials = await registerSession(page, testInfo, { allowMockSession: false })
const builder = new E2eTestDataBuilder(page, testInfo)
await builder.init()
const adminContext = await browser.newContext()
const adminPage = await adminContext.newPage()
const adminBuilder = new E2eTestDataBuilder(adminPage, testInfo)
await loginWithCredentials(adminPage, adminCredentials(), testInfo)
await adminBuilder.init()
const namespace = await adminBuilder.createNamespace('e2e-team')
const candidates = await adminBuilder.searchNamespaceMemberCandidates(namespace.slug, credentials.username)
const matchedCandidate = candidates.find((candidate) => matchCandidateUsername(candidate, credentials.username)) ?? candidates[0]
if (!matchedCandidate) {
throw new Error(`No namespace member candidate found for review actor ${credentials.username}`)
}
await adminBuilder.addNamespaceMember(namespace.slug, matchedCandidate.userId, 'ADMIN')
const skill = await builder.publishSkill(namespace.slug)
const reviewTaskId = await adminBuilder.waitForPendingReview(namespace.slug, skill.slug, skill.version)
return {
namespace,
skill,
reviewTaskId,
cleanup: async () => {
await builder.cleanup()
await adminBuilder.cleanup()
await adminContext.close()
},
}
}

View file

@ -10,6 +10,10 @@ export interface TestCredentials {
username: string
}
interface RegisterSessionOptions {
allowMockSession?: boolean
}
interface SessionSnapshot {
username: string
cookies: Array<{
@ -160,7 +164,7 @@ async function tryBootstrapMockSession(page: Page, worker: number): Promise<{ us
return { username: 'local-user', password }
}
async function registerSessionOnce(page: Page, testInfo?: TestInfo) {
async function registerSessionOnce(page: Page, testInfo?: TestInfo, options?: RegisterSessionOptions) {
const worker = testInfo?.parallelIndex ?? 0
const cached = cachedUserByWorker.get(worker)
const username = usernameForWorker(testInfo)
@ -175,9 +179,11 @@ async function registerSessionOnce(page: Page, testInfo?: TestInfo) {
return { username: restored.username, password }
}
const mockSession = await tryBootstrapMockSession(page, worker)
if (mockSession) {
return mockSession
if (options?.allowMockSession !== false) {
const mockSession = await tryBootstrapMockSession(page, worker)
if (mockSession) {
return mockSession
}
}
// Prefer the known-good cached account to avoid repeated failed-logins on a fixed username.
@ -317,12 +323,12 @@ async function createFreshSessionOnce(page: Page, testInfo?: TestInfo) {
throw new Error(`Failed to create fresh e2e session for worker ${worker}`)
}
export async function registerSession(page: Page, testInfo?: TestInfo) {
export async function registerSession(page: Page, testInfo?: TestInfo, options?: RegisterSessionOptions) {
let lastError: unknown
for (let attempt = 0; attempt < 3; attempt += 1) {
try {
return await registerSessionOnce(page, testInfo)
return await registerSessionOnce(page, testInfo, options)
} catch (error) {
lastError = error
if (attempt < 2) {

View file

@ -10,6 +10,11 @@ export interface SeededNamespace {
id: number
slug: string
displayName: string
status?: string
type?: string
currentUserRole?: string
canUnfreeze?: boolean
canRestore?: boolean
}
export interface SeededSkill {
@ -34,6 +39,13 @@ interface ReviewTaskSummary {
version: string
}
interface NamespaceCandidate {
userId: string
displayName: string
email?: string
status: string
}
interface ApiEnvelope<T> {
code: number
msg: string
@ -45,6 +57,8 @@ interface ApiFailure extends Error {
code?: number
}
const cleanupTimeoutMs = process.env.CI ? 8_000 : 5_000
export interface SeedSkillOptions {
name?: string
description?: string
@ -65,6 +79,24 @@ function uniqueSuffix(testInfo?: TestInfo): string {
return `${worker}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
}
async function runCleanupTaskWithTimeout(task: CleanupTask): Promise<void> {
await new Promise<void>((resolve, reject) => {
const timeout = setTimeout(() => {
reject(new Error(`cleanup task timed out after ${cleanupTimeoutMs}ms`))
}, cleanupTimeoutMs)
void task()
.then(() => {
clearTimeout(timeout)
resolve()
})
.catch((error) => {
clearTimeout(timeout)
reject(error)
})
})
}
function buildSkillPackageContent(suffix: string, options?: SeedSkillOptions) {
const skillName = (options?.name || `e2e-skill-${suffix}`).slice(0, 48)
const description = options?.description || 'E2E generated skill for real-request tests'
@ -166,7 +198,7 @@ export class E2eTestDataBuilder {
async cleanup(): Promise<void> {
for (let i = this.cleanupTasks.length - 1; i >= 0; i -= 1) {
try {
await this.cleanupTasks[i]()
await runCleanupTaskWithTimeout(this.cleanupTasks[i])
} catch {
// Best-effort cleanup for E2E environments.
}
@ -207,6 +239,34 @@ export class E2eTestDataBuilder {
)
}
private isTeamNamespace(namespace: SeededNamespace): boolean {
return namespace.type === 'TEAM' || namespace.slug !== 'global'
}
private isActiveNamespace(namespace: SeededNamespace): boolean {
return namespace.status === 'ACTIVE'
}
private async activateNamespace(namespace: SeededNamespace): Promise<SeededNamespace | null> {
if (!this.isTeamNamespace(namespace)) {
return null
}
if (namespace.status === 'FROZEN' && namespace.canUnfreeze) {
return parseEnvelope<SeededNamespace>(
await this.request.post(`/api/web/namespaces/${encodeURIComponent(namespace.slug)}/unfreeze`),
)
}
if (namespace.status === 'ARCHIVED' && namespace.canRestore) {
return parseEnvelope<SeededNamespace>(
await this.request.post(`/api/web/namespaces/${encodeURIComponent(namespace.slug)}/restore`),
)
}
return null
}
async ensureWritableNamespace(): Promise<SeededNamespace> {
if (this.ensuredNamespace) {
return this.ensuredNamespace
@ -224,12 +284,75 @@ export class E2eTestDataBuilder {
}
const namespaces = await this.listMyNamespaces()
const writable = namespaces.find((item) => item.slug !== 'global') ?? namespaces[0]
if (!writable) {
throw new Error('No namespace available for e2e data seeding')
const activeTeam = namespaces.find((item) => this.isTeamNamespace(item) && this.isActiveNamespace(item))
if (activeTeam) {
this.ensuredNamespace = activeTeam
return activeTeam
}
this.ensuredNamespace = writable
return writable
const activeFallback = namespaces.find((item) => this.isActiveNamespace(item))
if (activeFallback) {
this.ensuredNamespace = activeFallback
return activeFallback
}
const activatable = namespaces.find((item) =>
this.isTeamNamespace(item)
&& ((item.status === 'FROZEN' && item.canUnfreeze) || (item.status === 'ARCHIVED' && item.canRestore)),
)
if (activatable) {
const activated = await this.activateNamespace(activatable)
if (activated) {
this.ensuredNamespace = activated
return activated
}
}
const summary = namespaces
.map((item) => `${item.slug}:${item.status ?? 'UNKNOWN'}`)
.join(', ')
throw new Error(`No active writable namespace available for e2e data seeding [${summary}]`)
}
async ensureReviewableNamespace(): Promise<SeededNamespace> {
if (this.ensuredNamespace && this.isTeamNamespace(this.ensuredNamespace) && this.isActiveNamespace(this.ensuredNamespace)) {
return this.ensuredNamespace
}
try {
const created = await this.createNamespace('e2e-team')
this.ensuredNamespace = created
return created
} catch (error) {
const failure = error as ApiFailure
if (failure.status !== 403) {
throw error
}
}
const namespaces = await this.listMyNamespaces()
const activeTeam = namespaces.find((item) => this.isTeamNamespace(item) && this.isActiveNamespace(item))
if (activeTeam) {
this.ensuredNamespace = activeTeam
return activeTeam
}
const activatable = namespaces.find((item) =>
this.isTeamNamespace(item)
&& ((item.status === 'FROZEN' && item.canUnfreeze) || (item.status === 'ARCHIVED' && item.canRestore)),
)
if (activatable) {
const activated = await this.activateNamespace(activatable)
if (activated) {
this.ensuredNamespace = activated
return activated
}
}
const summary = namespaces
.map((item) => `${item.slug}:${item.status ?? 'UNKNOWN'}`)
.join(', ')
throw new Error(`No TEAM namespace available for review E2E data seeding [${summary}]`)
}
private async getMySkillInNamespace(namespaceSlug: string): Promise<SeededSkill | null> {
@ -350,6 +473,21 @@ export class E2eTestDataBuilder {
)
}
async searchNamespaceMemberCandidates(slug: string, search: string): Promise<NamespaceCandidate[]> {
const query = new URLSearchParams({ search })
return parseEnvelope<NamespaceCandidate[]>(
await this.request.get(`/api/web/namespaces/${encodeURIComponent(slug)}/member-candidates?${query.toString()}`),
)
}
async addNamespaceMember(slug: string, userId: string, role: 'MEMBER' | 'ADMIN' | 'OWNER' = 'MEMBER'): Promise<void> {
await parseEnvelope<{ userId: string; role: string }>(
await this.request.post(`/api/web/namespaces/${encodeURIComponent(slug)}/members`, {
data: { userId, role },
}),
)
}
async publishSkill(namespaceSlug: string, options?: SeedSkillOptions): Promise<SeededSkill> {
const unique = `${this.suffix}_${Math.random().toString(36).slice(2, 6)}`
const zipBuffer = buildSkillPackageZipBuffer(unique, options)
@ -384,7 +522,7 @@ export class E2eTestDataBuilder {
}
async createReviewData(): Promise<SeededReviewData> {
const namespace = await this.ensureWritableNamespace()
const namespace = await this.ensureReviewableNamespace()
const skill = await this.publishSkill(namespace.slug)
return { namespace, skill }
}

Some files were not shown because too many files have changed in this diff Show more