mirror of
https://github.com/iflytek/skillhub.git
synced 2026-09-22 00:31:21 +00:00
Merge branch 'main' into fix/runtime-auth-env-vars
This commit is contained in:
commit
d0f0462b58
144 changed files with 6925 additions and 575 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
17
README.md
17
README.md
|
|
@ -8,6 +8,7 @@
|
|||
|
||||
[](https://deepwiki.com/iflytek/skillhub)
|
||||
[](https://zread.ai/iflytek/skillhub)
|
||||
[](https://discord.gg/qHYvtDNPHS)
|
||||
[](./LICENSE)
|
||||
[](https://github.com/iflytek/skillhub/actions/workflows/publish-images.yml)
|
||||
[](https://ghcr.io/iflytek/skillhub)
|
||||
|
|
@ -95,7 +96,7 @@ The `--public-url` parameter sets the public access URL for your SkillHub instan
|
|||
**For users in China (Aliyun mirror):**
|
||||
|
||||
```bash
|
||||
curl -fsSL https://imageless.oss-cn-beijing.aliyuncs.com/runtime.sh | sh -s -- up --aliyun --public-url https://skillhub.your-company.com
|
||||
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**:
|
||||
|
||||

|
||||
|
|
|
|||
16
README_zh.md
16
README_zh.md
|
|
@ -7,6 +7,7 @@
|
|||
<div align="center">
|
||||
|
||||
[](https://zread.ai/iflytek/skillhub)
|
||||
[](https://discord.gg/qHYvtDNPHS)
|
||||
[](./LICENSE)
|
||||
[](https://github.com/iflytek/skillhub/actions/workflows/publish-images.yml)
|
||||
[](https://ghcr.io/iflytek/skillhub)
|
||||
|
|
@ -67,7 +68,7 @@ curl -fsSL https://imageless.oss-cn-beijing.aliyuncs.com/runtime.sh | sh -s -- u
|
|||
**国内用户(阿里云镜像):**
|
||||
|
||||
```bash
|
||||
curl -fsSL https://imageless.oss-cn-beijing.aliyuncs.com/runtime.sh | sh -s -- up --aliyun --public-url https://skillhub.your-company.com
|
||||
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 slug,SkillHub 会将其解析为
|
||||
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)
|
||||
- 👥 **企业微信群**:
|
||||
|
||||

|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 个(可配置)
|
||||
|
|
|
|||
|
|
@ -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 裸金属上线清单
|
||||
|
||||
|
|
|
|||
317
docs/19-smtp-password-reset-email-setup.md
Normal file
317
docs/19-smtp-password-reset-email-setup.md
Normal 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 授权码。
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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 slug,SkillHub 会将其解析为 namespace `my-space` 和 skill slug `my-skill`
|
||||
- 为避免 CLI 展示与服务端最终坐标不一致,建议让 `SKILL.md` 中的 `name` 与 canonical slug 后半段保持一致
|
||||
|
||||
## API 端点说明
|
||||
|
||||
SkillHub 兼容层提供以下端点:
|
||||
|
|
|
|||
460
docs/oss-01-core-contract-freeze.md
Normal file
460
docs/oss-01-core-contract-freeze.md
Normal 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 → YANKED,downloadReady → 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 被删除/归档/隐藏,或版本被 yank,AstronClaw 本地安装快照仍可正常使用和卸载。
|
||||
|
||||
## 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 的复杂关系。
|
||||
663
docs/oss-02-core-semantic-rules.md
Normal file
663
docs/oss-02-core-semantic-rules.md
Normal 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
|
||||
visibility:PRIVATE
|
||||
↓
|
||||
owner 可下载/安装/测试
|
||||
市场不可见
|
||||
管理员可见(用于审计)
|
||||
已有检测报告
|
||||
↓
|
||||
owner 测试满意,确认发布(confirm-publish)
|
||||
↓
|
||||
状态:PUBLISHED
|
||||
visibility:PRIVATE(正式私有版本)
|
||||
↓
|
||||
owner 可下载/安装
|
||||
市场不可见
|
||||
↓
|
||||
用户想公开,提交审核
|
||||
↓
|
||||
状态:PENDING_REVIEW
|
||||
requestedVisibility:PUBLIC
|
||||
↓
|
||||
owner 仍可下载/测试
|
||||
↓
|
||||
审核通过
|
||||
↓
|
||||
状态:PUBLISHED
|
||||
visibility:PUBLIC(不再是 PRIVATE)
|
||||
↓
|
||||
市场可见,所有人可下载
|
||||
```
|
||||
|
||||
### 4.3 PUBLIC/NAMESPACE_ONLY skill 生命周期
|
||||
|
||||
```
|
||||
用户发布 PUBLIC/NAMESPACE_ONLY skill
|
||||
↓
|
||||
状态:PENDING_REVIEW
|
||||
↓
|
||||
owner 可下载/测试
|
||||
↓
|
||||
审核通过
|
||||
↓
|
||||
状态:PUBLISHED
|
||||
visibility:PUBLIC 或 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 处理
|
||||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -83,7 +83,8 @@ public class ClawHubRegistryFacade {
|
|||
CompatSkillLookupService.CompatSkillContext context = compatSkillLookupService.resolveVisible(
|
||||
coordinate.namespace(),
|
||||
coordinate.slug(),
|
||||
userId
|
||||
userId,
|
||||
normalizeRoles(userNsRoles)
|
||||
);
|
||||
Skill skill = context.skill();
|
||||
|
||||
|
|
|
|||
|
|
@ -1,13 +1,16 @@
|
|||
package com.iflytek.skillhub.compat;
|
||||
|
||||
import com.iflytek.skillhub.domain.namespace.Namespace;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceRepository;
|
||||
import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException;
|
||||
import com.iflytek.skillhub.domain.skill.Skill;
|
||||
import com.iflytek.skillhub.domain.skill.SkillRepository;
|
||||
import com.iflytek.skillhub.domain.skill.SkillVersion;
|
||||
import com.iflytek.skillhub.domain.skill.SkillVersionRepository;
|
||||
import com.iflytek.skillhub.domain.skill.VisibilityChecker;
|
||||
import com.iflytek.skillhub.domain.skill.service.SkillSlugResolutionService;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
|
|
@ -22,15 +25,18 @@ public class CompatSkillLookupService {
|
|||
private final NamespaceRepository namespaceRepository;
|
||||
private final SkillVersionRepository skillVersionRepository;
|
||||
private final SkillSlugResolutionService skillSlugResolutionService;
|
||||
private final VisibilityChecker visibilityChecker;
|
||||
|
||||
public CompatSkillLookupService(SkillRepository skillRepository,
|
||||
NamespaceRepository namespaceRepository,
|
||||
SkillVersionRepository skillVersionRepository,
|
||||
SkillSlugResolutionService skillSlugResolutionService) {
|
||||
SkillSlugResolutionService skillSlugResolutionService,
|
||||
VisibilityChecker visibilityChecker) {
|
||||
this.skillRepository = skillRepository;
|
||||
this.namespaceRepository = namespaceRepository;
|
||||
this.skillVersionRepository = skillVersionRepository;
|
||||
this.skillSlugResolutionService = skillSlugResolutionService;
|
||||
this.visibilityChecker = visibilityChecker;
|
||||
}
|
||||
|
||||
public CompatSkillContext findByLegacySlug(String slug) {
|
||||
|
|
@ -41,10 +47,28 @@ public class CompatSkillLookupService {
|
|||
return new CompatSkillContext(namespace, skill, findLatestVersion(skill));
|
||||
}
|
||||
|
||||
public boolean canAccess(Skill skill, String currentUserId, Map<Long, NamespaceRole> userNsRoles) {
|
||||
if (skill == null) {
|
||||
return false;
|
||||
}
|
||||
Map<Long, NamespaceRole> roles = userNsRoles != null ? userNsRoles : Map.of();
|
||||
return visibilityChecker.canAccess(skill, currentUserId, roles);
|
||||
}
|
||||
|
||||
public CompatSkillContext resolveVisible(String namespaceSlug, String skillSlug, String currentUserId) {
|
||||
return resolveVisible(namespaceSlug, skillSlug, currentUserId, Map.of());
|
||||
}
|
||||
|
||||
public CompatSkillContext resolveVisible(String namespaceSlug,
|
||||
String skillSlug,
|
||||
String currentUserId,
|
||||
Map<Long, NamespaceRole> userNsRoles) {
|
||||
Namespace namespace = namespaceRepository.findBySlug(namespaceSlug)
|
||||
.orElseThrow(() -> new DomainNotFoundException("error.namespace.notFound", namespaceSlug));
|
||||
Skill skill = resolveVisibleSkill(namespace.getId(), skillSlug, currentUserId);
|
||||
if (!canAccess(skill, currentUserId, userNsRoles)) {
|
||||
throw new DomainNotFoundException("error.skill.notFound", skillSlug);
|
||||
}
|
||||
return new CompatSkillContext(namespace, skill, findLatestVersion(skill));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -55,8 +55,10 @@ public class NamespaceController extends BaseApiController {
|
|||
}
|
||||
|
||||
@GetMapping("/namespaces")
|
||||
public ApiResponse<PageResponse<NamespaceResponse>> listNamespaces(Pageable pageable) {
|
||||
return ok("response.success.read", namespacePortalQueryAppService.listNamespaces(pageable));
|
||||
public ApiResponse<PageResponse<NamespaceResponse>> listNamespaces(
|
||||
Pageable pageable,
|
||||
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> userNsRoles) {
|
||||
return ok("response.success.read", namespacePortalQueryAppService.listNamespaces(pageable, userNsRoles));
|
||||
}
|
||||
|
||||
@GetMapping("/me/namespaces")
|
||||
|
|
@ -68,7 +70,7 @@ public class NamespaceController extends BaseApiController {
|
|||
|
||||
@GetMapping("/namespaces/{slug}")
|
||||
public ApiResponse<NamespaceResponse> getNamespace(@PathVariable String slug,
|
||||
@RequestAttribute(value = "userId", required = false) String userId,
|
||||
@RequestAttribute("userId") String userId,
|
||||
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> userNsRoles) {
|
||||
return ok("response.success.read",
|
||||
namespacePortalQueryAppService.getNamespace(slug, userId, userNsRoles));
|
||||
|
|
|
|||
|
|
@ -103,6 +103,10 @@ public class SecurityAuditController extends BaseApiController {
|
|||
return true;
|
||||
}
|
||||
Map<Long, NamespaceRole> namespaceRoles = userNsRoles != null ? userNsRoles : Map.of();
|
||||
NamespaceRole namespaceRole = namespaceRoles.get(skill.getNamespaceId());
|
||||
if (namespaceRole == NamespaceRole.ADMIN || namespaceRole == NamespaceRole.OWNER) {
|
||||
return true;
|
||||
}
|
||||
return visibilityChecker.canAccess(skill, principal.userId(), namespaceRoles);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ public class MultipartPackageExtractor {
|
|||
}
|
||||
|
||||
public record PublishPayload(
|
||||
String namespace,
|
||||
String slug,
|
||||
String displayName,
|
||||
String version,
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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
|
||||
) {}
|
||||
|
|
@ -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
|
||||
) {}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
) {
|
||||
}
|
||||
|
|
@ -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
|
||||
) {
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ import jakarta.validation.constraints.NotBlank;
|
|||
|
||||
public record SkillVersionRereleaseRequest(
|
||||
@NotBlank(message = "{validation.required}")
|
||||
String targetVersion
|
||||
String targetVersion,
|
||||
boolean confirmWarnings
|
||||
) {
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
) {}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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=新密码不能为空
|
||||
|
|
|
|||
|
|
@ -0,0 +1,80 @@
|
|||
package com.iflytek.skillhub.compat;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import com.iflytek.skillhub.controller.support.MultipartPackageExtractor;
|
||||
import com.iflytek.skillhub.controller.support.ZipPackageExtractor;
|
||||
import com.iflytek.skillhub.domain.audit.AuditLogService;
|
||||
import com.iflytek.skillhub.domain.namespace.Namespace;
|
||||
import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException;
|
||||
import com.iflytek.skillhub.domain.skill.Skill;
|
||||
import com.iflytek.skillhub.domain.skill.SkillVisibility;
|
||||
import com.iflytek.skillhub.domain.skill.service.SkillPublishService;
|
||||
import com.iflytek.skillhub.domain.skill.service.SkillQueryService;
|
||||
import com.iflytek.skillhub.domain.social.SkillStarService;
|
||||
import com.iflytek.skillhub.service.SkillSearchAppService;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class ClawHubCompatAppServiceTest {
|
||||
|
||||
private final SkillSearchAppService skillSearchAppService = mock(SkillSearchAppService.class);
|
||||
private final SkillQueryService skillQueryService = mock(SkillQueryService.class);
|
||||
private final SkillPublishService skillPublishService = mock(SkillPublishService.class);
|
||||
private final ZipPackageExtractor zipPackageExtractor = mock(ZipPackageExtractor.class);
|
||||
private final MultipartPackageExtractor multipartPackageExtractor = mock(MultipartPackageExtractor.class);
|
||||
private final AuditLogService auditLogService = mock(AuditLogService.class);
|
||||
private final CompatSkillLookupService compatSkillLookupService = mock(CompatSkillLookupService.class);
|
||||
private final SkillStarService skillStarService = mock(SkillStarService.class);
|
||||
|
||||
private final ClawHubCompatAppService service = new ClawHubCompatAppService(
|
||||
new CanonicalSlugMapper(),
|
||||
skillSearchAppService,
|
||||
skillQueryService,
|
||||
skillPublishService,
|
||||
zipPackageExtractor,
|
||||
multipartPackageExtractor,
|
||||
auditLogService,
|
||||
compatSkillLookupService,
|
||||
skillStarService
|
||||
);
|
||||
|
||||
@Test
|
||||
void downloadLocationByQuery_throwsNotFound_whenLegacySkillIsPrivateForAnonymousCaller() {
|
||||
Namespace namespace = new Namespace("team-a", "Team A", "owner-1");
|
||||
Skill privateSkill = new Skill(1L, "priv", "owner-1", SkillVisibility.PRIVATE);
|
||||
CompatSkillLookupService.CompatSkillContext context = new CompatSkillLookupService.CompatSkillContext(
|
||||
namespace,
|
||||
privateSkill,
|
||||
Optional.empty()
|
||||
);
|
||||
|
||||
when(compatSkillLookupService.findByLegacySlug("priv")).thenReturn(context);
|
||||
when(compatSkillLookupService.canAccess(privateSkill, null, Map.of())).thenReturn(false);
|
||||
|
||||
assertThatThrownBy(() -> service.downloadLocationByQuery("priv", "latest", null, null))
|
||||
.isInstanceOf(DomainNotFoundException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void downloadLocationByQuery_returnsCanonicalPath_whenLegacySkillIsVisible() {
|
||||
Namespace namespace = new Namespace("team-a", "Team A", "owner-1");
|
||||
Skill publicSkill = new Skill(1L, "my-skill", "owner-1", SkillVisibility.PUBLIC);
|
||||
CompatSkillLookupService.CompatSkillContext context = new CompatSkillLookupService.CompatSkillContext(
|
||||
namespace,
|
||||
publicSkill,
|
||||
Optional.empty()
|
||||
);
|
||||
|
||||
when(compatSkillLookupService.findByLegacySlug("my-skill")).thenReturn(context);
|
||||
when(compatSkillLookupService.canAccess(publicSkill, null, Map.of())).thenReturn(true);
|
||||
|
||||
String location = service.downloadLocationByQuery("my-skill", "latest", null, null);
|
||||
|
||||
assertThat(location).isEqualTo("/api/v1/skills/team-a/my-skill/download");
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,102 @@
|
|||
package com.iflytek.skillhub.compat;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.ArgumentMatchers.isNull;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
import com.iflytek.skillhub.auth.device.DeviceAuthService;
|
||||
import com.iflytek.skillhub.compat.dto.ClawHubSkillResponse;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
|
||||
import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException;
|
||||
import java.util.Map;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.boot.test.mock.mockito.MockBean;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
|
||||
@SpringBootTest
|
||||
@AutoConfigureMockMvc
|
||||
@ActiveProfiles("test")
|
||||
class ClawHubCompatControllerSecurityTest {
|
||||
|
||||
@Autowired
|
||||
private MockMvc mockMvc;
|
||||
|
||||
@MockBean
|
||||
private NamespaceMemberRepository namespaceMemberRepository;
|
||||
|
||||
@MockBean
|
||||
private DeviceAuthService deviceAuthService;
|
||||
|
||||
@MockBean
|
||||
private ClawHubCompatAppService clawHubCompatAppService;
|
||||
|
||||
@Test
|
||||
void getSkill_returnsNotFound_whenAnonymousCannotAccessPrivateSkill() throws Exception {
|
||||
when(clawHubCompatAppService.getSkill(eq("priv"), isNull(), isNull()))
|
||||
.thenThrow(new DomainNotFoundException("error.skill.notFound", "priv"));
|
||||
|
||||
mockMvc.perform(get("/api/v1/skills/priv"))
|
||||
.andExpect(status().isNotFound());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getSkill_returnsSkill_whenCallerHasNamespacePermission() throws Exception {
|
||||
var roles = Map.of(1L, NamespaceRole.ADMIN);
|
||||
var response = new ClawHubSkillResponse(
|
||||
new ClawHubSkillResponse.SkillInfo(
|
||||
"team-ai--priv",
|
||||
"Private Skill",
|
||||
"summary",
|
||||
Map.of(),
|
||||
Map.of(),
|
||||
0L,
|
||||
0L
|
||||
),
|
||||
null,
|
||||
null,
|
||||
new ClawHubSkillResponse.ModerationInfo(false, false, "clean", new String[0], null, null, null)
|
||||
);
|
||||
when(clawHubCompatAppService.getSkill("team-ai--priv", "admin-1", roles)).thenReturn(response);
|
||||
|
||||
mockMvc.perform(get("/api/v1/skills/team-ai--priv")
|
||||
.requestAttr("userId", "admin-1")
|
||||
.requestAttr("userNsRoles", roles))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.skill.slug").value("team-ai--priv"));
|
||||
|
||||
verify(clawHubCompatAppService).getSkill("team-ai--priv", "admin-1", roles);
|
||||
}
|
||||
|
||||
@Test
|
||||
void downloadQuery_returnsNotFound_whenAnonymousCannotAccessPrivateLegacySlug() throws Exception {
|
||||
when(clawHubCompatAppService.downloadLocationByQuery(eq("priv"), eq("latest"), isNull(), isNull()))
|
||||
.thenThrow(new DomainNotFoundException("error.skill.notFound", "priv"));
|
||||
|
||||
mockMvc.perform(get("/api/v1/download")
|
||||
.param("slug", "priv")
|
||||
.param("version", "latest"))
|
||||
.andExpect(status().isNotFound());
|
||||
}
|
||||
|
||||
@Test
|
||||
void downloadQuery_returnsNotFound_whenUserWithoutNamespaceRoleAccessesPrivateLegacySlug() throws Exception {
|
||||
when(clawHubCompatAppService.downloadLocationByQuery("priv", "latest", "user-1", Map.of()))
|
||||
.thenThrow(new DomainNotFoundException("error.skill.notFound", "priv"));
|
||||
|
||||
mockMvc.perform(get("/api/v1/download")
|
||||
.param("slug", "priv")
|
||||
.param("version", "latest")
|
||||
.requestAttr("userId", "user-1")
|
||||
.requestAttr("userNsRoles", Map.of()))
|
||||
.andExpect(status().isNotFound());
|
||||
}
|
||||
}
|
||||
|
|
@ -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"))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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!");
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import java.util.Map;
|
|||
import java.util.TimeZone;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.ArgumentMatchers.anySet;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
package com.iflytek.skillhub.controller.portal;
|
||||
|
||||
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceMember;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
|
||||
import com.iflytek.skillhub.domain.security.ScannerType;
|
||||
import com.iflytek.skillhub.domain.security.SecurityAudit;
|
||||
|
|
@ -56,6 +58,9 @@ class SecurityAuditControllerTest {
|
|||
@MockBean
|
||||
private ScanTaskProducer scanTaskProducer;
|
||||
|
||||
@MockBean
|
||||
private NamespaceMemberRepository namespaceMemberRepository;
|
||||
|
||||
@Test
|
||||
void getSecurityAudit_returnsAuditPayload() throws Exception {
|
||||
SecurityAudit audit = new SecurityAudit(42L, ScannerType.SKILL_SCANNER);
|
||||
|
|
@ -129,6 +134,29 @@ class SecurityAuditControllerTest {
|
|||
.andExpect(jsonPath("$.code").value(403));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getSecurityAudit_allowsNamespaceAdminForPendingUnpublishedSkill() throws Exception {
|
||||
SecurityAudit audit = new SecurityAudit(42L, ScannerType.SKILL_SCANNER);
|
||||
setField(audit, "id", 9L);
|
||||
audit.setScanId("scan-team-admin");
|
||||
audit.setVerdict(SecurityVerdict.SAFE);
|
||||
audit.setIsSafe(true);
|
||||
audit.setMaxSeverity("LOW");
|
||||
audit.setFindingsCount(0);
|
||||
given(skillVersionRepository.findById(42L)).willReturn(java.util.Optional.of(skillVersion(42L, 8L)));
|
||||
given(skillRepository.findById(8L)).willReturn(java.util.Optional.of(skill(8L, "owner-1")));
|
||||
given(securityAuditRepository.findLatestActiveByVersionId(42L)).willReturn(List.of(audit));
|
||||
given(namespaceMemberRepository.findByUserId("team-admin"))
|
||||
.willReturn(List.of(new NamespaceMember(5L, "team-admin", NamespaceRole.ADMIN)));
|
||||
|
||||
mockMvc.perform(get("/api/v1/skills/8/versions/42/security-audit")
|
||||
.with(auth("team-admin")))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(0))
|
||||
.andExpect(jsonPath("$.data[0].id").value(9L))
|
||||
.andExpect(jsonPath("$.data[0].scanId").value("scan-team-admin"));
|
||||
}
|
||||
|
||||
private RequestPostProcessor auth(String userId) {
|
||||
PlatformPrincipal principal = new PlatformPrincipal(
|
||||
userId,
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
|
|||
import com.iflytek.skillhub.auth.rbac.RbacService;
|
||||
import com.iflytek.skillhub.domain.namespace.Namespace;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceRepository;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceType;
|
||||
import com.iflytek.skillhub.domain.review.ReviewTask;
|
||||
|
|
@ -128,6 +129,37 @@ class SkillApprovalVisibilityFlowIntegrationTest {
|
|||
assertThat(indexedDocument.getTitle()).isEqualTo(graph.skill().getDisplayName());
|
||||
}
|
||||
|
||||
@Test
|
||||
void namespaceAdminCanApproveOwnTeamReview() throws Exception {
|
||||
PendingSkillGraph graph = createPendingTeamSkill("team-admin");
|
||||
when(namespaceMemberRepository.findByUserId("team-admin"))
|
||||
.thenReturn(List.of(new com.iflytek.skillhub.domain.namespace.NamespaceMember(
|
||||
graph.namespace().getId(),
|
||||
"team-admin",
|
||||
NamespaceRole.ADMIN
|
||||
)));
|
||||
when(rbacService.getUserRoleCodes("team-admin")).thenReturn(Set.of());
|
||||
|
||||
mockMvc.perform(post("/api/v1/reviews/" + graph.reviewTask().getId() + "/approve")
|
||||
.contentType("application/json")
|
||||
.content("{\"comment\":\"approved by namespace admin\"}")
|
||||
.with(authentication(apiAuth("team-admin")))
|
||||
.with(csrf()))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(0))
|
||||
.andExpect(jsonPath("$.data.id").value(graph.reviewTask().getId()))
|
||||
.andExpect(jsonPath("$.data.status").value("APPROVED"))
|
||||
.andExpect(jsonPath("$.data.reviewedBy").value("team-admin"))
|
||||
.andExpect(jsonPath("$.data.reviewComment").value("approved by namespace admin"));
|
||||
|
||||
Skill savedSkill = skillRepository.findById(graph.skill().getId()).orElseThrow();
|
||||
SkillVersion savedVersion = skillVersionRepository.findById(graph.version().getId()).orElseThrow();
|
||||
|
||||
assertThat(savedSkill.getLatestVersionId()).isEqualTo(graph.version().getId());
|
||||
assertThat(savedVersion.getStatus()).isEqualTo(SkillVersionStatus.PUBLISHED);
|
||||
assertThat(savedVersion.getPublishedAt()).isNotNull();
|
||||
}
|
||||
|
||||
private PendingSkillGraph createPendingGlobalSkill(String ownerId) {
|
||||
String suffix = UUID.randomUUID().toString().substring(0, 8);
|
||||
|
||||
|
|
@ -154,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);
|
||||
|
|
|
|||
|
|
@ -212,7 +212,8 @@ class SkillLifecycleControllerTest {
|
|||
eq("1.2.3"),
|
||||
eq("1.2.4"),
|
||||
eq("usr_1"),
|
||||
anyMap()))
|
||||
anyMap(),
|
||||
eq(false)))
|
||||
.willReturn(new SkillPublishService.PublishResult(1L, "demo-skill", newVersion));
|
||||
|
||||
mockMvc.perform(post("/api/web/skills/global/demo-skill/versions/1.2.3/rerelease")
|
||||
|
|
@ -279,7 +280,8 @@ class SkillLifecycleControllerTest {
|
|||
eq("1.2.3"),
|
||||
eq("1.2.4"),
|
||||
eq("usr_1"),
|
||||
anyMap()))
|
||||
anyMap(),
|
||||
eq(false)))
|
||||
.willReturn(new SkillPublishService.PublishResult(1L, "demo-skill", newVersion));
|
||||
|
||||
mockMvc.perform(post("/api/web/skills/global/demo-skill/versions/1.2.3/rerelease")
|
||||
|
|
@ -299,7 +301,44 @@ class SkillLifecycleControllerTest {
|
|||
eq("1.2.3"),
|
||||
eq("1.2.4"),
|
||||
eq("usr_1"),
|
||||
anyMap());
|
||||
anyMap(),
|
||||
eq(false));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rereleaseVersion_passesConfirmWarningsToService() throws Exception {
|
||||
Namespace namespace = new Namespace("global", "Global", "owner");
|
||||
setNamespaceId(namespace, 1L);
|
||||
Skill skill = new Skill(1L, "demo-skill", "owner", SkillVisibility.PUBLIC);
|
||||
setSkillId(skill, 1L);
|
||||
SkillVersion newVersion = new SkillVersion(1L, "1.2.4", "owner");
|
||||
setSkillVersionId(newVersion, 3L);
|
||||
newVersion.setStatus(SkillVersionStatus.PUBLISHED);
|
||||
|
||||
given(namespaceRepository.findBySlug("global")).willReturn(java.util.Optional.of(namespace));
|
||||
given(skillSlugResolutionService.resolve(1L, "demo-skill", "usr_1", SkillSlugResolutionService.Preference.CURRENT_USER))
|
||||
.willReturn(skill);
|
||||
SkillVersion sourceVersion = new SkillVersion(1L, "1.2.3", "owner");
|
||||
setSkillVersionId(sourceVersion, 2L);
|
||||
sourceVersion.setStatus(SkillVersionStatus.PUBLISHED);
|
||||
given(skillVersionRepository.findBySkillIdAndVersion(1L, "1.2.3")).willReturn(java.util.Optional.of(sourceVersion));
|
||||
given(skillPublishService.rereleasePublishedVersion(
|
||||
eq(1L), eq("1.2.3"), eq("1.2.4"), eq("usr_1"), anyMap(), eq(true)))
|
||||
.willReturn(new SkillPublishService.PublishResult(1L, "demo-skill", newVersion));
|
||||
|
||||
mockMvc.perform(post("/api/web/skills/global/demo-skill/versions/1.2.3/rerelease")
|
||||
.requestAttr("userId", "usr_1")
|
||||
.requestAttr("userNsRoles", java.util.Map.of(1L, NamespaceRole.ADMIN))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"targetVersion\":\"1.2.4\",\"confirmWarnings\":true}")
|
||||
.with(user("usr_1"))
|
||||
.with(csrf()))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(0))
|
||||
.andExpect(jsonPath("$.data.action").value("RERELEASE_VERSION"));
|
||||
|
||||
verify(skillPublishService).rereleasePublishedVersion(
|
||||
eq(1L), eq("1.2.3"), eq("1.2.4"), eq("usr_1"), anyMap(), eq(true));
|
||||
}
|
||||
|
||||
private Skill skillWithStatus(Skill skill, com.iflytek.skillhub.domain.skill.SkillStatus status) {
|
||||
|
|
|
|||
|
|
@ -4,6 +4,9 @@ import static org.mockito.ArgumentMatchers.anyList;
|
|||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.BDDMockito.given;
|
||||
import static org.mockito.Mockito.verify;
|
||||
|
||||
import com.iflytek.skillhub.domain.skill.validation.PackageEntry;
|
||||
import org.mockito.ArgumentMatchers;
|
||||
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication;
|
||||
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart;
|
||||
|
|
@ -69,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)) {
|
||||
|
|
|
|||
|
|
@ -35,13 +35,14 @@ class PrometheusEndpointTest {
|
|||
private DeviceAuthService deviceAuthService;
|
||||
|
||||
@Test
|
||||
void prometheusEndpoint_exposesCustomMetrics() {
|
||||
void metricsRegistry_stillRecordsCustomMetrics_whenPrometheusEndpointIsDisabled() {
|
||||
skillHubMetrics.incrementUserRegister();
|
||||
skillHubMetrics.recordLocalLogin(true);
|
||||
skillHubMetrics.incrementSkillPublish("global", "PENDING_REVIEW");
|
||||
|
||||
assertThat(environment.getProperty("management.endpoints.web.exposure.include"))
|
||||
.contains("prometheus");
|
||||
.doesNotContain("prometheus")
|
||||
.doesNotContain("metrics");
|
||||
assertThat(meterRegistry.get("skillhub.user.register").counter().count()).isEqualTo(1.0d);
|
||||
assertThat(meterRegistry.get("skillhub.auth.login")
|
||||
.tag("method", "local")
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package com.iflytek.skillhub.service;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyList;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
|
|
@ -16,6 +17,7 @@ import com.iflytek.skillhub.domain.namespace.NamespaceRole;
|
|||
import com.iflytek.skillhub.domain.namespace.NamespaceService;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceStatus;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceType;
|
||||
import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException;
|
||||
import com.iflytek.skillhub.domain.user.UserAccount;
|
||||
import com.iflytek.skillhub.domain.user.UserAccountRepository;
|
||||
import com.iflytek.skillhub.dto.MemberResponse;
|
||||
|
|
@ -71,6 +73,39 @@ class NamespacePortalQueryAppServiceTest {
|
|||
assertThat(response.get(1).currentUserRole()).isEqualTo(NamespaceRole.ADMIN);
|
||||
}
|
||||
|
||||
@Test
|
||||
void listNamespaces_returnsOnlyCurrentUsersActiveNamespaces() {
|
||||
Namespace teamA = namespace(1L, "team-a");
|
||||
Namespace teamB = namespace(2L, "team-b");
|
||||
Namespace archived = namespace(3L, "archived");
|
||||
archived.setStatus(NamespaceStatus.ARCHIVED);
|
||||
|
||||
when(namespaceRepository.findByIdIn(anyList())).thenReturn(List.of(teamB, archived, teamA));
|
||||
|
||||
var response = service.listNamespaces(
|
||||
PageRequest.of(0, 10),
|
||||
Map.of(
|
||||
1L, NamespaceRole.MEMBER,
|
||||
2L, NamespaceRole.ADMIN,
|
||||
3L, NamespaceRole.OWNER
|
||||
)
|
||||
);
|
||||
|
||||
assertThat(response.items()).hasSize(2);
|
||||
assertThat(response.items().get(0).slug()).isEqualTo("team-a");
|
||||
assertThat(response.items().get(1).slug()).isEqualTo("team-b");
|
||||
}
|
||||
|
||||
@Test
|
||||
void getNamespace_throwsWhenCurrentUserIsNotNamespaceMember() {
|
||||
Namespace namespace = namespace(1L, "team-a");
|
||||
when(namespaceService.getNamespaceBySlugForRead("team-a", "user-1", Map.of()))
|
||||
.thenReturn(namespace);
|
||||
|
||||
assertThatThrownBy(() -> service.getNamespace("team-a", "user-1", Map.of()))
|
||||
.isInstanceOf(DomainForbiddenException.class);
|
||||
}
|
||||
|
||||
private Namespace namespace(Long id, String slug) {
|
||||
Namespace namespace = new Namespace(slug, slug, "owner-1");
|
||||
ReflectionTestUtils.setField(namespace, "id", id);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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.";
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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/**")
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
/**
|
||||
* Password-reset domain entities and repository contracts.
|
||||
*/
|
||||
package com.iflytek.skillhub.domain.auth;
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ public enum SkillVersionStatus {
|
|||
DRAFT,
|
||||
SCANNING,
|
||||
SCAN_FAILED,
|
||||
UPLOADED,
|
||||
PENDING_REVIEW,
|
||||
PUBLISHED,
|
||||
REJECTED,
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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) {}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,13 +26,37 @@ class ReviewPermissionCheckerTest {
|
|||
}
|
||||
|
||||
@Test
|
||||
void skillAdminCannotReviewOwnSubmission() {
|
||||
void skillAdminCannotReviewOwnSubmissionWithoutNamespaceRole() {
|
||||
String userId = "user-1";
|
||||
ReviewTask task = new ReviewTask(1L, 10L, userId);
|
||||
assertFalse(checker.canReview(task, userId,
|
||||
NamespaceType.TEAM, Map.of(), Set.of("SKILL_ADMIN")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void skillAdminNamespaceAdminCanReviewOwnSubmission() {
|
||||
String userId = "user-1";
|
||||
ReviewTask task = new ReviewTask(1L, 10L, userId);
|
||||
assertTrue(checker.canReview(task, userId,
|
||||
NamespaceType.TEAM, Map.of(10L, NamespaceRole.ADMIN), Set.of("SKILL_ADMIN")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void skillAdminNamespaceOwnerCanReviewOwnSubmission() {
|
||||
String userId = "user-1";
|
||||
ReviewTask task = new ReviewTask(1L, 10L, userId);
|
||||
assertTrue(checker.canReview(task, userId,
|
||||
NamespaceType.TEAM, Map.of(10L, NamespaceRole.OWNER), Set.of("SKILL_ADMIN")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void skillAdminNamespaceMemberCannotReviewOwnSubmission() {
|
||||
String userId = "user-1";
|
||||
ReviewTask task = new ReviewTask(1L, 10L, userId);
|
||||
assertFalse(checker.canReview(task, userId,
|
||||
NamespaceType.TEAM, Map.of(10L, NamespaceRole.MEMBER), Set.of("SKILL_ADMIN")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void superAdminCannotReviewOwnSubmission() {
|
||||
String userId = "user-1";
|
||||
|
|
@ -57,6 +81,24 @@ class ReviewPermissionCheckerTest {
|
|||
NamespaceType.GLOBAL, Map.of(), Set.of("SUPER_ADMIN")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void teamAdminCanReviewOwnTeamSubmission() {
|
||||
String userId = "user-1";
|
||||
ReviewTask task = new ReviewTask(1L, 10L, userId);
|
||||
assertTrue(checker.canReview(task, userId,
|
||||
NamespaceType.TEAM,
|
||||
Map.of(10L, NamespaceRole.ADMIN), Set.of()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void teamOwnerCanReviewOwnTeamSubmission() {
|
||||
String userId = "user-1";
|
||||
ReviewTask task = new ReviewTask(1L, 10L, userId);
|
||||
assertTrue(checker.canReview(task, userId,
|
||||
NamespaceType.TEAM,
|
||||
Map.of(10L, NamespaceRole.OWNER), Set.of()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void teamAdminCanReviewTeamSkill() {
|
||||
ReviewTask task = new ReviewTask(1L, 10L, "user-2");
|
||||
|
|
|
|||
|
|
@ -483,6 +483,46 @@ class ReviewServiceTest {
|
|||
assertEquals(USER_ID, skill.getUpdatedBy());
|
||||
}
|
||||
|
||||
@Test
|
||||
void namespaceAdminCanApproveOwnSubmission() {
|
||||
ReviewTask task = createPendingReviewTask();
|
||||
Namespace ns = createTeamNamespace();
|
||||
SkillVersion sv = createPendingReviewSkillVersion();
|
||||
Skill skill = createSkill();
|
||||
|
||||
when(reviewTaskRepository.findById(REVIEW_TASK_ID)).thenReturn(Optional.of(task));
|
||||
when(namespaceRepository.findById(NAMESPACE_ID)).thenReturn(Optional.of(ns));
|
||||
when(permissionChecker.canReview(
|
||||
eq(task),
|
||||
eq(USER_ID),
|
||||
eq(ns.getType()),
|
||||
eq(Map.of(NAMESPACE_ID, NamespaceRole.ADMIN)),
|
||||
eq(Set.of())))
|
||||
.thenReturn(true);
|
||||
when(reviewTaskRepository.updateStatusWithVersion(
|
||||
REVIEW_TASK_ID,
|
||||
ReviewTaskStatus.APPROVED,
|
||||
USER_ID,
|
||||
"self approved as namespace admin",
|
||||
task.getVersion()))
|
||||
.thenReturn(1);
|
||||
when(skillVersionRepository.findById(SKILL_VERSION_ID)).thenReturn(Optional.of(sv));
|
||||
when(skillRepository.findById(SKILL_ID)).thenReturn(Optional.of(skill));
|
||||
when(skillRepository.findByNamespaceIdAndSlug(NAMESPACE_ID, "my-skill")).thenReturn(List.of(skill));
|
||||
when(reviewTaskRepository.findById(REVIEW_TASK_ID)).thenReturn(Optional.of(task));
|
||||
|
||||
ReviewTask result = reviewService.approveReview(
|
||||
REVIEW_TASK_ID,
|
||||
USER_ID,
|
||||
"self approved as namespace admin",
|
||||
Map.of(NAMESPACE_ID, NamespaceRole.ADMIN),
|
||||
Set.of());
|
||||
|
||||
assertNotNull(result);
|
||||
assertEquals(SkillVersionStatus.PUBLISHED, sv.getStatus());
|
||||
assertEquals(USER_ID, skill.getUpdatedBy());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldThrowOnConcurrentModification() {
|
||||
ReviewTask task = createPendingReviewTask();
|
||||
|
|
@ -602,6 +642,43 @@ class ReviewServiceTest {
|
|||
assertEquals(SkillVersionStatus.REJECTED, sv.getStatus());
|
||||
}
|
||||
|
||||
@Test
|
||||
void namespaceAdminCanRejectOwnSubmission() {
|
||||
ReviewTask task = createPendingReviewTask();
|
||||
Namespace ns = createTeamNamespace();
|
||||
SkillVersion sv = createPendingReviewSkillVersion();
|
||||
|
||||
when(reviewTaskRepository.findById(REVIEW_TASK_ID)).thenReturn(Optional.of(task));
|
||||
when(namespaceRepository.findById(NAMESPACE_ID)).thenReturn(Optional.of(ns));
|
||||
when(permissionChecker.canReview(
|
||||
eq(task),
|
||||
eq(USER_ID),
|
||||
eq(ns.getType()),
|
||||
eq(Map.of(NAMESPACE_ID, NamespaceRole.ADMIN)),
|
||||
eq(Set.of())))
|
||||
.thenReturn(true);
|
||||
when(reviewTaskRepository.updateStatusWithVersion(
|
||||
REVIEW_TASK_ID,
|
||||
ReviewTaskStatus.REJECTED,
|
||||
USER_ID,
|
||||
"self rejected as namespace admin",
|
||||
task.getVersion()))
|
||||
.thenReturn(1);
|
||||
when(skillVersionRepository.findById(SKILL_VERSION_ID)).thenReturn(Optional.of(sv));
|
||||
when(skillRepository.findById(SKILL_ID)).thenReturn(Optional.of(createSkill()));
|
||||
when(reviewTaskRepository.findById(REVIEW_TASK_ID)).thenReturn(Optional.of(task));
|
||||
|
||||
ReviewTask result = reviewService.rejectReview(
|
||||
REVIEW_TASK_ID,
|
||||
USER_ID,
|
||||
"self rejected as namespace admin",
|
||||
Map.of(NAMESPACE_ID, NamespaceRole.ADMIN),
|
||||
Set.of());
|
||||
|
||||
assertNotNull(result);
|
||||
assertEquals(SkillVersionStatus.REJECTED, sv.getStatus());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldThrowOnConcurrentModification() {
|
||||
ReviewTask task = createPendingReviewTask();
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import org.junit.jupiter.api.BeforeEach;
|
|||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
|
|
@ -158,4 +159,34 @@ class VisibilityCheckerTest {
|
|||
boolean canAccess = checker.canAccess(hiddenPublicSkill, ADMIN_USER_ID, roles);
|
||||
assertTrue(canAccess);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testSuperAdminCanAccessPrivateSkill() {
|
||||
boolean canAccess = checker.canAccess(privateSkill, OTHER_USER_ID, Map.of(), Set.of("SUPER_ADMIN"));
|
||||
assertTrue(canAccess);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testSuperAdminCanAccessHiddenSkill() {
|
||||
boolean canAccess = checker.canAccess(hiddenPublicSkill, OTHER_USER_ID, Map.of(), Set.of("SUPER_ADMIN"));
|
||||
assertTrue(canAccess);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testSuperAdminCanAccessUnpublishedSkill() {
|
||||
boolean canAccess = checker.canAccess(unpublishedPublicSkill, OTHER_USER_ID, Map.of(), Set.of("SUPER_ADMIN"));
|
||||
assertTrue(canAccess);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testNonSuperAdminPlatformRolesDoNotGrantAccess() {
|
||||
boolean canAccess = checker.canAccess(privateSkill, OTHER_USER_ID, Map.of(), Set.of("REVIEWER"));
|
||||
assertFalse(canAccess);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testEmptyPlatformRolesDoNotGrantAccess() {
|
||||
boolean canAccess = checker.canAccess(privateSkill, OTHER_USER_ID, Map.of(), Set.of());
|
||||
assertFalse(canAccess);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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")));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}`,
|
||||
})
|
||||
}
|
||||
|
|
|
|||
64
web/e2e/helpers/review-seed.ts
Normal file
64
web/e2e/helpers/review-seed.ts
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
import type { Browser, Page, TestInfo } from '@playwright/test'
|
||||
import { loginWithCredentials, registerSession } from './session'
|
||||
import { E2eTestDataBuilder, type SeededReviewData } from './test-data-builder'
|
||||
|
||||
function getOptionalEnv(name: string): string | undefined {
|
||||
const value = process.env[name]?.trim()
|
||||
return value ? value : undefined
|
||||
}
|
||||
|
||||
function adminCredentials() {
|
||||
return {
|
||||
username: getOptionalEnv('E2E_ADMIN_USERNAME') ?? getOptionalEnv('BOOTSTRAP_ADMIN_USERNAME') ?? 'admin',
|
||||
password: getOptionalEnv('E2E_ADMIN_PASSWORD') ?? getOptionalEnv('BOOTSTRAP_ADMIN_PASSWORD') ?? 'ChangeMe!2026',
|
||||
}
|
||||
}
|
||||
|
||||
function matchCandidateUsername(
|
||||
candidate: { userId: string; displayName: string; email?: string },
|
||||
username: string,
|
||||
) {
|
||||
return candidate.userId === username
|
||||
|| candidate.displayName === username
|
||||
|| candidate.email === `${username}@example.test`
|
||||
}
|
||||
|
||||
export async function createNamespaceReviewData(
|
||||
browser: Browser,
|
||||
page: Page,
|
||||
testInfo: TestInfo,
|
||||
): Promise<SeededReviewData & { reviewTaskId: number; cleanup: () => Promise<void> }> {
|
||||
const credentials = await registerSession(page, testInfo, { allowMockSession: false })
|
||||
const builder = new E2eTestDataBuilder(page, testInfo)
|
||||
await builder.init()
|
||||
|
||||
const adminContext = await browser.newContext()
|
||||
const adminPage = await adminContext.newPage()
|
||||
const adminBuilder = new E2eTestDataBuilder(adminPage, testInfo)
|
||||
|
||||
await loginWithCredentials(adminPage, adminCredentials(), testInfo)
|
||||
await adminBuilder.init()
|
||||
|
||||
const namespace = await adminBuilder.createNamespace('e2e-team')
|
||||
const candidates = await adminBuilder.searchNamespaceMemberCandidates(namespace.slug, credentials.username)
|
||||
const matchedCandidate = candidates.find((candidate) => matchCandidateUsername(candidate, credentials.username)) ?? candidates[0]
|
||||
|
||||
if (!matchedCandidate) {
|
||||
throw new Error(`No namespace member candidate found for review actor ${credentials.username}`)
|
||||
}
|
||||
|
||||
await adminBuilder.addNamespaceMember(namespace.slug, matchedCandidate.userId, 'ADMIN')
|
||||
const skill = await builder.publishSkill(namespace.slug)
|
||||
const reviewTaskId = await adminBuilder.waitForPendingReview(namespace.slug, skill.slug, skill.version)
|
||||
|
||||
return {
|
||||
namespace,
|
||||
skill,
|
||||
reviewTaskId,
|
||||
cleanup: async () => {
|
||||
await builder.cleanup()
|
||||
await adminBuilder.cleanup()
|
||||
await adminContext.close()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -10,6 +10,10 @@ export interface TestCredentials {
|
|||
username: string
|
||||
}
|
||||
|
||||
interface RegisterSessionOptions {
|
||||
allowMockSession?: boolean
|
||||
}
|
||||
|
||||
interface SessionSnapshot {
|
||||
username: string
|
||||
cookies: Array<{
|
||||
|
|
@ -160,7 +164,7 @@ async function tryBootstrapMockSession(page: Page, worker: number): Promise<{ us
|
|||
return { username: 'local-user', password }
|
||||
}
|
||||
|
||||
async function registerSessionOnce(page: Page, testInfo?: TestInfo) {
|
||||
async function registerSessionOnce(page: Page, testInfo?: TestInfo, options?: RegisterSessionOptions) {
|
||||
const worker = testInfo?.parallelIndex ?? 0
|
||||
const cached = cachedUserByWorker.get(worker)
|
||||
const username = usernameForWorker(testInfo)
|
||||
|
|
@ -175,9 +179,11 @@ async function registerSessionOnce(page: Page, testInfo?: TestInfo) {
|
|||
return { username: restored.username, password }
|
||||
}
|
||||
|
||||
const mockSession = await tryBootstrapMockSession(page, worker)
|
||||
if (mockSession) {
|
||||
return mockSession
|
||||
if (options?.allowMockSession !== false) {
|
||||
const mockSession = await tryBootstrapMockSession(page, worker)
|
||||
if (mockSession) {
|
||||
return mockSession
|
||||
}
|
||||
}
|
||||
|
||||
// Prefer the known-good cached account to avoid repeated failed-logins on a fixed username.
|
||||
|
|
@ -317,12 +323,12 @@ async function createFreshSessionOnce(page: Page, testInfo?: TestInfo) {
|
|||
throw new Error(`Failed to create fresh e2e session for worker ${worker}`)
|
||||
}
|
||||
|
||||
export async function registerSession(page: Page, testInfo?: TestInfo) {
|
||||
export async function registerSession(page: Page, testInfo?: TestInfo, options?: RegisterSessionOptions) {
|
||||
let lastError: unknown
|
||||
|
||||
for (let attempt = 0; attempt < 3; attempt += 1) {
|
||||
try {
|
||||
return await registerSessionOnce(page, testInfo)
|
||||
return await registerSessionOnce(page, testInfo, options)
|
||||
} catch (error) {
|
||||
lastError = error
|
||||
if (attempt < 2) {
|
||||
|
|
|
|||
|
|
@ -10,6 +10,11 @@ export interface SeededNamespace {
|
|||
id: number
|
||||
slug: string
|
||||
displayName: string
|
||||
status?: string
|
||||
type?: string
|
||||
currentUserRole?: string
|
||||
canUnfreeze?: boolean
|
||||
canRestore?: boolean
|
||||
}
|
||||
|
||||
export interface SeededSkill {
|
||||
|
|
@ -34,6 +39,13 @@ interface ReviewTaskSummary {
|
|||
version: string
|
||||
}
|
||||
|
||||
interface NamespaceCandidate {
|
||||
userId: string
|
||||
displayName: string
|
||||
email?: string
|
||||
status: string
|
||||
}
|
||||
|
||||
interface ApiEnvelope<T> {
|
||||
code: number
|
||||
msg: string
|
||||
|
|
@ -45,6 +57,8 @@ interface ApiFailure extends Error {
|
|||
code?: number
|
||||
}
|
||||
|
||||
const cleanupTimeoutMs = process.env.CI ? 8_000 : 5_000
|
||||
|
||||
export interface SeedSkillOptions {
|
||||
name?: string
|
||||
description?: string
|
||||
|
|
@ -65,6 +79,24 @@ function uniqueSuffix(testInfo?: TestInfo): string {
|
|||
return `${worker}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
|
||||
}
|
||||
|
||||
async function runCleanupTaskWithTimeout(task: CleanupTask): Promise<void> {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
reject(new Error(`cleanup task timed out after ${cleanupTimeoutMs}ms`))
|
||||
}, cleanupTimeoutMs)
|
||||
|
||||
void task()
|
||||
.then(() => {
|
||||
clearTimeout(timeout)
|
||||
resolve()
|
||||
})
|
||||
.catch((error) => {
|
||||
clearTimeout(timeout)
|
||||
reject(error)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function buildSkillPackageContent(suffix: string, options?: SeedSkillOptions) {
|
||||
const skillName = (options?.name || `e2e-skill-${suffix}`).slice(0, 48)
|
||||
const description = options?.description || 'E2E generated skill for real-request tests'
|
||||
|
|
@ -166,7 +198,7 @@ export class E2eTestDataBuilder {
|
|||
async cleanup(): Promise<void> {
|
||||
for (let i = this.cleanupTasks.length - 1; i >= 0; i -= 1) {
|
||||
try {
|
||||
await this.cleanupTasks[i]()
|
||||
await runCleanupTaskWithTimeout(this.cleanupTasks[i])
|
||||
} catch {
|
||||
// Best-effort cleanup for E2E environments.
|
||||
}
|
||||
|
|
@ -207,6 +239,34 @@ export class E2eTestDataBuilder {
|
|||
)
|
||||
}
|
||||
|
||||
private isTeamNamespace(namespace: SeededNamespace): boolean {
|
||||
return namespace.type === 'TEAM' || namespace.slug !== 'global'
|
||||
}
|
||||
|
||||
private isActiveNamespace(namespace: SeededNamespace): boolean {
|
||||
return namespace.status === 'ACTIVE'
|
||||
}
|
||||
|
||||
private async activateNamespace(namespace: SeededNamespace): Promise<SeededNamespace | null> {
|
||||
if (!this.isTeamNamespace(namespace)) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (namespace.status === 'FROZEN' && namespace.canUnfreeze) {
|
||||
return parseEnvelope<SeededNamespace>(
|
||||
await this.request.post(`/api/web/namespaces/${encodeURIComponent(namespace.slug)}/unfreeze`),
|
||||
)
|
||||
}
|
||||
|
||||
if (namespace.status === 'ARCHIVED' && namespace.canRestore) {
|
||||
return parseEnvelope<SeededNamespace>(
|
||||
await this.request.post(`/api/web/namespaces/${encodeURIComponent(namespace.slug)}/restore`),
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
async ensureWritableNamespace(): Promise<SeededNamespace> {
|
||||
if (this.ensuredNamespace) {
|
||||
return this.ensuredNamespace
|
||||
|
|
@ -224,12 +284,75 @@ export class E2eTestDataBuilder {
|
|||
}
|
||||
|
||||
const namespaces = await this.listMyNamespaces()
|
||||
const writable = namespaces.find((item) => item.slug !== 'global') ?? namespaces[0]
|
||||
if (!writable) {
|
||||
throw new Error('No namespace available for e2e data seeding')
|
||||
const activeTeam = namespaces.find((item) => this.isTeamNamespace(item) && this.isActiveNamespace(item))
|
||||
if (activeTeam) {
|
||||
this.ensuredNamespace = activeTeam
|
||||
return activeTeam
|
||||
}
|
||||
this.ensuredNamespace = writable
|
||||
return writable
|
||||
|
||||
const activeFallback = namespaces.find((item) => this.isActiveNamespace(item))
|
||||
if (activeFallback) {
|
||||
this.ensuredNamespace = activeFallback
|
||||
return activeFallback
|
||||
}
|
||||
|
||||
const activatable = namespaces.find((item) =>
|
||||
this.isTeamNamespace(item)
|
||||
&& ((item.status === 'FROZEN' && item.canUnfreeze) || (item.status === 'ARCHIVED' && item.canRestore)),
|
||||
)
|
||||
if (activatable) {
|
||||
const activated = await this.activateNamespace(activatable)
|
||||
if (activated) {
|
||||
this.ensuredNamespace = activated
|
||||
return activated
|
||||
}
|
||||
}
|
||||
|
||||
const summary = namespaces
|
||||
.map((item) => `${item.slug}:${item.status ?? 'UNKNOWN'}`)
|
||||
.join(', ')
|
||||
throw new Error(`No active writable namespace available for e2e data seeding [${summary}]`)
|
||||
}
|
||||
|
||||
async ensureReviewableNamespace(): Promise<SeededNamespace> {
|
||||
if (this.ensuredNamespace && this.isTeamNamespace(this.ensuredNamespace) && this.isActiveNamespace(this.ensuredNamespace)) {
|
||||
return this.ensuredNamespace
|
||||
}
|
||||
|
||||
try {
|
||||
const created = await this.createNamespace('e2e-team')
|
||||
this.ensuredNamespace = created
|
||||
return created
|
||||
} catch (error) {
|
||||
const failure = error as ApiFailure
|
||||
if (failure.status !== 403) {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
const namespaces = await this.listMyNamespaces()
|
||||
const activeTeam = namespaces.find((item) => this.isTeamNamespace(item) && this.isActiveNamespace(item))
|
||||
if (activeTeam) {
|
||||
this.ensuredNamespace = activeTeam
|
||||
return activeTeam
|
||||
}
|
||||
|
||||
const activatable = namespaces.find((item) =>
|
||||
this.isTeamNamespace(item)
|
||||
&& ((item.status === 'FROZEN' && item.canUnfreeze) || (item.status === 'ARCHIVED' && item.canRestore)),
|
||||
)
|
||||
if (activatable) {
|
||||
const activated = await this.activateNamespace(activatable)
|
||||
if (activated) {
|
||||
this.ensuredNamespace = activated
|
||||
return activated
|
||||
}
|
||||
}
|
||||
|
||||
const summary = namespaces
|
||||
.map((item) => `${item.slug}:${item.status ?? 'UNKNOWN'}`)
|
||||
.join(', ')
|
||||
throw new Error(`No TEAM namespace available for review E2E data seeding [${summary}]`)
|
||||
}
|
||||
|
||||
private async getMySkillInNamespace(namespaceSlug: string): Promise<SeededSkill | null> {
|
||||
|
|
@ -350,6 +473,21 @@ export class E2eTestDataBuilder {
|
|||
)
|
||||
}
|
||||
|
||||
async searchNamespaceMemberCandidates(slug: string, search: string): Promise<NamespaceCandidate[]> {
|
||||
const query = new URLSearchParams({ search })
|
||||
return parseEnvelope<NamespaceCandidate[]>(
|
||||
await this.request.get(`/api/web/namespaces/${encodeURIComponent(slug)}/member-candidates?${query.toString()}`),
|
||||
)
|
||||
}
|
||||
|
||||
async addNamespaceMember(slug: string, userId: string, role: 'MEMBER' | 'ADMIN' | 'OWNER' = 'MEMBER'): Promise<void> {
|
||||
await parseEnvelope<{ userId: string; role: string }>(
|
||||
await this.request.post(`/api/web/namespaces/${encodeURIComponent(slug)}/members`, {
|
||||
data: { userId, role },
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
async publishSkill(namespaceSlug: string, options?: SeedSkillOptions): Promise<SeededSkill> {
|
||||
const unique = `${this.suffix}_${Math.random().toString(36).slice(2, 6)}`
|
||||
const zipBuffer = buildSkillPackageZipBuffer(unique, options)
|
||||
|
|
@ -384,7 +522,7 @@ export class E2eTestDataBuilder {
|
|||
}
|
||||
|
||||
async createReviewData(): Promise<SeededReviewData> {
|
||||
const namespace = await this.ensureWritableNamespace()
|
||||
const namespace = await this.ensureReviewableNamespace()
|
||||
const skill = await this.publishSkill(namespace.slug)
|
||||
return { namespace, skill }
|
||||
}
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue