diff --git a/.env.release.draft b/.env.release.draft index 8a0c28e27..400582667 100644 --- a/.env.release.draft +++ b/.env.release.draft @@ -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 diff --git a/.env.release.example b/.env.release.example index ffb6ca845..3a7af8dc0 100644 --- a/.env.release.example +++ b/.env.release.example @@ -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 diff --git a/README.md b/README.md index cf803028c..9a8f6c397 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@ [![DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/iflytek/skillhub) [![Docs](https://img.shields.io/badge/docs-zread.ai-4A90E2?logo=gitbook&logoColor=white)](https://zread.ai/iflytek/skillhub) +[![Discord](https://img.shields.io/badge/discord-join-5865F2?logo=discord&logoColor=white)](https://discord.gg/qHYvtDNPHS) [![License](https://img.shields.io/badge/license-Apache%202.0-blue.svg)](./LICENSE) [![Build](https://github.com/iflytek/skillhub/actions/workflows/publish-images.yml/badge.svg)](https://github.com/iflytek/skillhub/actions/workflows/publish-images.yml) [![Docker](https://img.shields.io/badge/docker-ghcr.io-2496ED?logo=docker&logoColor=white)](https://ghcr.io/iflytek/skillhub) @@ -95,7 +96,7 @@ The `--public-url` parameter sets the public access URL for your SkillHub instan **For users in China (Aliyun mirror):** ```bash -curl -fsSL https://imageless.oss-cn-beijing.aliyuncs.com/runtime.sh | sh -s -- up --aliyun --public-url https://skillhub.your-company.com +curl -fsSL https://imageless.oss-cn-beijing.aliyuncs.com/runtime.sh | sh -s -- up --aliyun --public-url https://skillhub.your-company.com --version latest ``` If deployment runs into problems, clear the existing runtime home and retry. @@ -195,7 +196,7 @@ Published images target both `linux/amd64` and `linux/arm64`. curl -fsSL https://imageless.oss-cn-beijing.aliyuncs.com/runtime.sh | sh -s -- up --public-url https://skillhub.your-company.com # Aliyun mirror (recommended for users in China) -curl -fsSL https://imageless.oss-cn-beijing.aliyuncs.com/runtime.sh | sh -s -- up --aliyun --public-url https://skillhub.your-company.com +curl -fsSL https://imageless.oss-cn-beijing.aliyuncs.com/runtime.sh | sh -s -- up --aliyun --public-url https://skillhub.your-company.com --version latest ``` **Deployment parameters:** @@ -222,6 +223,7 @@ cp .env.release.example .env.release Recommended image tags: +- `SKILLHUB_VERSION=latest` for the latest stable release (default) - `SKILLHUB_VERSION=edge` for the latest `main` build - `SKILLHUB_VERSION=vX.Y.Z` for a fixed release @@ -396,10 +398,16 @@ npx clawhub search email npx clawhub install my-skill npx clawhub install my-namespace--my-skill -# Publish a skill -npx clawhub publish ./my-skill +# Publish to global namespace +npx clawhub publish ./my-skill --slug my-skill --version 1.0.0 + +# Publish to a team namespace such as my-space +npx clawhub publish ./my-skill --slug my-space--my-skill --version 1.0.0 ``` +`my-space--my-skill` is the canonical compat slug. SkillHub parses it as +namespace `my-space` plus skill slug `my-skill`. + > 💡 **Tip**: The above commands are not only applicable to OpenClaw, but also to other CLI Coding Agents or Agent assistants by specifying the installation directory (`--dir`). For example: `npx clawhub --dir ~/.claude/skills install my-skill` 📖 **[Complete OpenClaw Integration Guide →](./docs/openclaw-integration.md)** @@ -434,6 +442,7 @@ what you'd like to change. - 💬 **Community Discussion**: [GitHub Discussions](https://github.com/iflytek/skillhub/discussions) - 🐛 **Bug Reports**: [Issues](https://github.com/iflytek/skillhub/issues) +- 👾 **Discord**: [Join our Server](https://discord.gg/qHYvtDNPHS) - 👥 **WeChat Work Group**: ![WeChat Work Group](https://github.com/iflytek/astron-agent/raw/main/docs/imgs/WeCom_Group.png) diff --git a/README_zh.md b/README_zh.md index edb58fc82..72c7eb738 100644 --- a/README_zh.md +++ b/README_zh.md @@ -7,6 +7,7 @@
[![文档](https://img.shields.io/badge/docs-zread.ai-4A90E2?logo=gitbook&logoColor=white)](https://zread.ai/iflytek/skillhub) +[![Discord](https://img.shields.io/badge/discord-join-5865F2?logo=discord&logoColor=white)](https://discord.gg/qHYvtDNPHS) [![许可证](https://img.shields.io/badge/license-Apache%202.0-blue.svg)](./LICENSE) [![构建](https://github.com/iflytek/skillhub/actions/workflows/publish-images.yml/badge.svg)](https://github.com/iflytek/skillhub/actions/workflows/publish-images.yml) [![Docker](https://img.shields.io/badge/docker-ghcr.io-2496ED?logo=docker&logoColor=white)](https://ghcr.io/iflytek/skillhub) @@ -67,7 +68,7 @@ curl -fsSL https://imageless.oss-cn-beijing.aliyuncs.com/runtime.sh | sh -s -- u **国内用户(阿里云镜像):** ```bash -curl -fsSL https://imageless.oss-cn-beijing.aliyuncs.com/runtime.sh | sh -s -- up --aliyun --public-url https://skillhub.your-company.com +curl -fsSL https://imageless.oss-cn-beijing.aliyuncs.com/runtime.sh | sh -s -- up --aliyun --public-url https://skillhub.your-company.com --version latest ``` 如果部署遇到问题,请清除现有的运行时目录并重试。 @@ -177,7 +178,7 @@ skillhub/ curl -fsSL https://imageless.oss-cn-beijing.aliyuncs.com/runtime.sh | sh -s -- up --public-url https://skillhub.your-company.com # 阿里云镜像(国内推荐) -curl -fsSL https://imageless.oss-cn-beijing.aliyuncs.com/runtime.sh | sh -s -- up --aliyun --public-url https://skillhub.your-company.com +curl -fsSL https://imageless.oss-cn-beijing.aliyuncs.com/runtime.sh | sh -s -- up --aliyun --public-url https://skillhub.your-company.com --version latest ``` ### 配置参数说明 @@ -331,10 +332,16 @@ npx clawhub search email npx clawhub install my-skill npx clawhub install my-namespace--my-skill -# 发布技能 -npx clawhub publish ./my-skill +# 发布到 global 空间 +npx clawhub publish ./my-skill --slug my-skill --version 1.0.0 + +# 发布到如 my-space 这样的团队空间 +npx clawhub publish ./my-skill --slug my-space--my-skill --version 1.0.0 ``` +其中 `my-space--my-skill` 是兼容层使用的 canonical 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) - 👥 **企业微信群**: ![企业微信群](https://github.com/iflytek/astron-agent/raw/main/docs/imgs/WeCom_Group.png) diff --git a/compose.release.yml b/compose.release.yml index e45b2069e..1b8787317 100644 --- a/compose.release.yml +++ b/compose.release.yml @@ -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: diff --git a/docker-compose.yml b/docker-compose.yml index 7bf18ffc4..77dc264b7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/docs/07-skill-protocol.md b/docs/07-skill-protocol.md index 958c4bb64..4bc141035 100644 --- a/docs/07-skill-protocol.md +++ b/docs/07-skill-protocol.md @@ -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 个(可配置) diff --git a/docs/09-deployment.md b/docs/09-deployment.md index 13b159aee..a8da42048 100644 --- a/docs/09-deployment.md +++ b/docs/09-deployment.md @@ -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 裸金属上线清单 diff --git a/docs/19-smtp-password-reset-email-setup.md b/docs/19-smtp-password-reset-email-setup.md new file mode 100644 index 000000000..07def9eff --- /dev/null +++ b/docs/19-smtp-password-reset-email-setup.md @@ -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=`(本地联调常用)。 +- 生产环境默认不建议配置 `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 授权码。 diff --git a/docs/openclaw-integration-en.md b/docs/openclaw-integration-en.md index 0e1b54d9d..32cb28781 100644 --- a/docs/openclaw-integration-en.md +++ b/docs/openclaw-integration-en.md @@ -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: diff --git a/docs/openclaw-integration.md b/docs/openclaw-integration.md index 17fd165bd..f85f588ea 100644 --- a/docs/openclaw-integration.md +++ b/docs/openclaw-integration.md @@ -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 兼容层提供以下端点: diff --git a/docs/oss-01-core-contract-freeze.md b/docs/oss-01-core-contract-freeze.md new file mode 100644 index 000000000..617f705c0 --- /dev/null +++ b/docs/oss-01-core-contract-freeze.md @@ -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` | +| 版本下载 | `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` | + +### 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: +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 的复杂关系。 diff --git a/docs/oss-02-core-semantic-rules.md b/docs/oss-02-core-semantic-rules.md new file mode 100644 index 000000000..484b17ef9 --- /dev/null +++ b/docs/oss-02-core-semantic-rules.md @@ -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 处理 diff --git a/server/Dockerfile b/server/Dockerfile index 1fe001414..84537b560 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -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"] diff --git a/server/Dockerfile.dev b/server/Dockerfile.dev index 77f96fcc3..7ec41c2a6 100644 --- a/server/Dockerfile.dev +++ b/server/Dockerfile.dev @@ -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"] diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/ClawHubCompatAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/ClawHubCompatAppService.java index 051e95669..b6fc5b1f6 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/ClawHubCompatAppService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/ClawHubCompatAppService.java @@ -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 userNsRoles) { - SkillCoordinate coord = resolveQueryCoordinate(slug); + SkillCoordinate coord = resolveQueryCoordinate(slug, userId, userNsRoles); + Map 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 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 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 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 normalizeRoles(Map 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 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, diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/ClawHubCompatController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/ClawHubCompatController.java index a66a7f0d2..2d499c314 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/ClawHubCompatController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/ClawHubCompatController.java @@ -82,8 +82,10 @@ public class ClawHubCompatController { @RateLimit(category = "download", authenticated = 60, anonymous = 20) @GetMapping("/download") public ResponseEntity 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 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 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") diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/ClawHubRegistryFacade.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/ClawHubRegistryFacade.java index 68dd32fc0..8c02af39b 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/ClawHubRegistryFacade.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/ClawHubRegistryFacade.java @@ -83,7 +83,8 @@ public class ClawHubRegistryFacade { CompatSkillLookupService.CompatSkillContext context = compatSkillLookupService.resolveVisible( coordinate.namespace(), coordinate.slug(), - userId + userId, + normalizeRoles(userNsRoles) ); Skill skill = context.skill(); diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/CompatSkillLookupService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/CompatSkillLookupService.java index a151ed8f6..9da699a24 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/CompatSkillLookupService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/CompatSkillLookupService.java @@ -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 userNsRoles) { + if (skill == null) { + return false; + } + Map 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 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)); } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/LocalAuthController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/LocalAuthController.java index 8d6f5e4ff..8442939df 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/LocalAuthController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/LocalAuthController.java @@ -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 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 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)) { diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/UserManagementController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/UserManagementController.java index efaf76477..627d413c3 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/UserManagementController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/UserManagementController.java @@ -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 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 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); + } } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/NamespaceController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/NamespaceController.java index e18fa681b..2f2c603ea 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/NamespaceController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/NamespaceController.java @@ -55,8 +55,10 @@ public class NamespaceController extends BaseApiController { } @GetMapping("/namespaces") - public ApiResponse> listNamespaces(Pageable pageable) { - return ok("response.success.read", namespacePortalQueryAppService.listNamespaces(pageable)); + public ApiResponse> listNamespaces( + Pageable pageable, + @RequestAttribute(value = "userNsRoles", required = false) Map 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 getNamespace(@PathVariable String slug, - @RequestAttribute(value = "userId", required = false) String userId, + @RequestAttribute("userId") String userId, @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles) { return ok("response.success.read", namespacePortalQueryAppService.getNamespace(slug, userId, userNsRoles)); diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SecurityAuditController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SecurityAuditController.java index cf233a74c..f763424ff 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SecurityAuditController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SecurityAuditController.java @@ -103,6 +103,10 @@ public class SecurityAuditController extends BaseApiController { return true; } Map 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); } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillLifecycleController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillLifecycleController.java index 16b7d2e44..b590fa22d 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillLifecycleController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillLifecycleController.java @@ -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 submitForReview(@PathVariable String namespace, + @PathVariable String slug, + @Valid @RequestBody SubmitReviewRequest request, + @RequestAttribute("userId") String userId, + @RequestAttribute(value = "userNsRoles", required = false) Map 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 confirmPublish(@PathVariable String namespace, + @PathVariable String slug, + @Valid @RequestBody ConfirmPublishRequest request, + @RequestAttribute("userId") String userId, + @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles, + HttpServletRequest httpRequest) { + return ok("response.success.updated", + governanceWorkflowAppService.confirmPublish( + namespace, + slug, + request.version(), + userId, + userNsRoles, + AuditRequestContext.from(httpRequest))); + } } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillPublishController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillPublishController.java index c853ca320..3abdf5e87 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillPublishController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillPublishController.java @@ -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( diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/MultipartPackageExtractor.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/MultipartPackageExtractor.java index 6e3e6a1c5..0a9fc793c 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/MultipartPackageExtractor.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/MultipartPackageExtractor.java @@ -30,6 +30,7 @@ public class MultipartPackageExtractor { } public record PublishPayload( + String namespace, String slug, String displayName, String version, diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/SkillPackageArchiveExtractor.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/SkillPackageArchiveExtractor.java index 9becbdd2d..5d98e93a2 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/SkillPackageArchiveExtractor.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/support/SkillPackageArchiveExtractor.java @@ -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"; diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/ConfirmPublishRequest.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/ConfirmPublishRequest.java new file mode 100644 index 000000000..cfcb29918 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/ConfirmPublishRequest.java @@ -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 +) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/LocalRegisterRequest.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/LocalRegisterRequest.java index 5a4a2749c..443a5b8e1 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/LocalRegisterRequest.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/LocalRegisterRequest.java @@ -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 ) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/PasswordResetConfirmRequest.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/PasswordResetConfirmRequest.java new file mode 100644 index 000000000..332f75e67 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/PasswordResetConfirmRequest.java @@ -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 +) { +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/PasswordResetRequestDto.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/PasswordResetRequestDto.java new file mode 100644 index 000000000..f12f20d21 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/PasswordResetRequestDto.java @@ -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 +) { +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillVersionRereleaseRequest.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillVersionRereleaseRequest.java index 6ca9e708e..b3d61fc93 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillVersionRereleaseRequest.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillVersionRereleaseRequest.java @@ -4,6 +4,7 @@ import jakarta.validation.constraints.NotBlank; public record SkillVersionRereleaseRequest( @NotBlank(message = "{validation.required}") - String targetVersion + String targetVersion, + boolean confirmWarnings ) { } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SubmitReviewRequest.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SubmitReviewRequest.java new file mode 100644 index 000000000..817e9c970 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SubmitReviewRequest.java @@ -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 +) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/filter/AuthContextFilter.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/filter/AuthContextFilter.java index 366038ee8..d4df24c38 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/filter/AuthContextFilter.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/filter/AuthContextFilter.java @@ -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 userNsRoles = namespaceMemberRepository.findByUserId(principal.userId()).stream() .collect(Collectors.toMap( NamespaceMember::getNamespaceId, diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/GovernanceWorkflowAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/GovernanceWorkflowAppService.java index 4432d0605..6cca39525 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/GovernanceWorkflowAppService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/GovernanceWorkflowAppService.java @@ -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 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 userNsRoles, + AuditRequestContext auditContext) { + return skillLifecycleAppService.confirmPublish( + namespace, + slug, + version, + userId, + userNsRoles, + auditContext); + } } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/NamespacePortalQueryAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/NamespacePortalQueryAppService.java index 6a21714e5..cff2eb594 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/NamespacePortalQueryAppService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/NamespacePortalQueryAppService.java @@ -8,6 +8,7 @@ import com.iflytek.skillhub.domain.namespace.NamespaceRepository; import com.iflytek.skillhub.domain.namespace.NamespaceRole; import com.iflytek.skillhub.domain.namespace.NamespaceService; import com.iflytek.skillhub.domain.namespace.NamespaceStatus; +import com.iflytek.skillhub.domain.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 listNamespaces(Pageable pageable) { - Page namespaces = namespaceRepository.findByStatus(NamespaceStatus.ACTIVE, pageable); - return PageResponse.from(namespaces.map(NamespaceResponse::from)); + public PageResponse listNamespaces(Pageable pageable, Map userNamespaceRoles) { + Map namespaceRoles = userNamespaceRoles != null ? userNamespaceRoles : Map.of(); + if (namespaceRoles.isEmpty()) { + Page empty = new PageImpl<>( + List.of(), + PageRequest.of(pageable.getPageNumber(), pageable.getPageSize()), + 0 + ); + return PageResponse.from(empty); + } + + List 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 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 userNamespaceRoles) { + Map 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); } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillLifecycleAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillLifecycleAppService.java index fda05a726..e7f86d459 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillLifecycleAppService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillLifecycleAppService.java @@ -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 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 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) diff --git a/server/skillhub-app/src/main/resources/application.yml b/server/skillhub-app/src/main/resources/application.yml index 8a3872afd..0cee466c3 100644 --- a/server/skillhub-app/src/main/resources/application.yml +++ b/server/skillhub-app/src/main/resources/application.yml @@ -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 diff --git a/server/skillhub-app/src/main/resources/db/migration/V39__password_reset_request.sql b/server/skillhub-app/src/main/resources/db/migration/V39__password_reset_request.sql new file mode 100644 index 000000000..c8915bbf8 --- /dev/null +++ b/server/skillhub-app/src/main/resources/db/migration/V39__password_reset_request.sql @@ -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'; diff --git a/server/skillhub-app/src/main/resources/messages.properties b/server/skillhub-app/src/main/resources/messages.properties index 83ac024fa..942d976ae 100644 --- a/server/skillhub-app/src/main/resources/messages.properties +++ b/server/skillhub-app/src/main/resources/messages.properties @@ -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 diff --git a/server/skillhub-app/src/main/resources/messages_zh.properties b/server/skillhub-app/src/main/resources/messages_zh.properties index abb834c34..bc94ca3bd 100644 --- a/server/skillhub-app/src/main/resources/messages_zh.properties +++ b/server/skillhub-app/src/main/resources/messages_zh.properties @@ -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=新密码不能为空 diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/ClawHubCompatAppServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/ClawHubCompatAppServiceTest.java new file mode 100644 index 000000000..c5921f59b --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/ClawHubCompatAppServiceTest.java @@ -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"); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/ClawHubCompatControllerSecurityTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/ClawHubCompatControllerSecurityTest.java new file mode 100644 index 000000000..eb731e823 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/ClawHubCompatControllerSecurityTest.java @@ -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()); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/ClawHubCompatControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/ClawHubCompatControllerTest.java index 1a03b6728..a78c7d739 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/ClawHubCompatControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/ClawHubCompatControllerTest.java @@ -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")) + ); + } } diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/CompatSkillLookupServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/CompatSkillLookupServiceTest.java new file mode 100644 index 000000000..3500ce04c --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/CompatSkillLookupServiceTest.java @@ -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); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/LocalAuthControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/LocalAuthControllerTest.java index bb5ee3ff7..7158cdfd9 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/LocalAuthControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/LocalAuthControllerTest.java @@ -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!"); + } + } diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/NamespacePortalControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/NamespacePortalControllerTest.java index 76775b1f5..404d6419f 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/NamespacePortalControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/NamespacePortalControllerTest.java @@ -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 diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/NamespaceWorkflowContractTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/NamespaceWorkflowContractTest.java index af279dc45..72b5d26dd 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/NamespaceWorkflowContractTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/NamespaceWorkflowContractTest.java @@ -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()) diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillControllerTest.java index 677336c78..e47da366d 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillControllerTest.java @@ -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; diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/admin/UserManagementControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/admin/UserManagementControllerTest.java index 2bb985678..1f01ff04d 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/admin/UserManagementControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/admin/UserManagementControllerTest.java @@ -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"); + } } diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SecurityAuditControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SecurityAuditControllerTest.java index ab4ddb2bb..eb5f87d6a 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SecurityAuditControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SecurityAuditControllerTest.java @@ -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, diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillApprovalVisibilityFlowIntegrationTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillApprovalVisibilityFlowIntegrationTest.java index 431223a84..1bd5f8796 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillApprovalVisibilityFlowIntegrationTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillApprovalVisibilityFlowIntegrationTest.java @@ -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 indexed = skillSearchDocumentJpaRepository.findBySkillId(skillId); while (indexed.isEmpty() && Instant.now().isBefore(deadline)) { Thread.sleep(100L); diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillLifecycleControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillLifecycleControllerTest.java index acf5a24f2..09751065f 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillLifecycleControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillLifecycleControllerTest.java @@ -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) { diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillPublishControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillPublishControllerTest.java index b7ffa2e14..ae5fd27bb 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillPublishControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillPublishControllerTest.java @@ -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.>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.>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)) { diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/metrics/PrometheusEndpointTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/metrics/PrometheusEndpointTest.java index 13c0be27e..38373f64f 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/metrics/PrometheusEndpointTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/metrics/PrometheusEndpointTest.java @@ -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") diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/NamespacePortalQueryAppServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/NamespacePortalQueryAppServiceTest.java index 7108fc40d..6091a9055 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/NamespacePortalQueryAppServiceTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/NamespacePortalQueryAppServiceTest.java @@ -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); diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillLifecycleAppServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillLifecycleAppServiceTest.java index 532150111..e2c101462 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillLifecycleAppServiceTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillLifecycleAppServiceTest.java @@ -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 ); diff --git a/server/skillhub-app/src/test/resources/application-test.yml b/server/skillhub-app/src/test/resources/application-test.yml index a3eb93b9d..e7a6ea698 100644 --- a/server/skillhub-app/src/test/resources/application-test.yml +++ b/server/skillhub-app/src/test/resources/application-test.yml @@ -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 diff --git a/server/skillhub-auth/pom.xml b/server/skillhub-auth/pom.xml index 5e50347d5..3bee6a9e9 100644 --- a/server/skillhub-auth/pom.xml +++ b/server/skillhub-auth/pom.xml @@ -35,6 +35,15 @@ org.springframework.boot spring-boot-starter-data-redis + + org.springframework.boot + spring-boot-starter-mail + + + org.springframework.boot + spring-boot-configuration-processor + true + org.springframework.boot spring-boot-starter-test diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/LocalAuthService.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/LocalAuthService.java index 9d378e25f..df0b1867b 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/LocalAuthService.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/LocalAuthService.java @@ -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"); diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/PasswordResetProperties.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/PasswordResetProperties.java new file mode 100644 index 000000000..00a031be3 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/PasswordResetProperties.java @@ -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; + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/PasswordResetService.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/PasswordResetService.java new file mode 100644 index 000000000..60b52accf --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/PasswordResetService.java @@ -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 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 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 pending = resetRequestRepository + .findByUserIdAndConsumedAtIsNullAndExpiresAtAfterOrderByCreatedAtDesc(userId, now); + for (PasswordResetRequest request : pending) { + request.markConsumed(now); + resetRequestRepository.save(request); + } + } + + private Optional findEligibleUserByEmail(String normalizedEmail) { + return findUserByEmail(normalizedEmail) + .filter(this::isEligibleForReset); + } + + private Optional 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."; + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/package-info.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/package-info.java index 7aa1200b9..80486dfd1 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/package-info.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/package-info.java @@ -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; diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistry.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistry.java index 498d20106..8235032cc 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistry.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistry.java @@ -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/**") ); diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/local/LocalAuthServiceTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/local/LocalAuthServiceTest.java index b566b9225..6b9f43446 100644 --- a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/local/LocalAuthServiceTest.java +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/local/LocalAuthServiceTest.java @@ -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"); + } } diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/local/PasswordResetServiceTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/local/PasswordResetServiceTest.java new file mode 100644 index 000000000..aa78c1fbe --- /dev/null +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/local/PasswordResetServiceTest.java @@ -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 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); + } +} diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistryTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistryTest.java index 8c227dd31..f6c8d7421 100644 --- a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistryTest.java +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/policy/RouteSecurityPolicyRegistryTest.java @@ -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)); diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/auth/PasswordResetRequest.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/auth/PasswordResetRequest.java new file mode 100644 index 000000000..2c0346751 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/auth/PasswordResetRequest.java @@ -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; + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/auth/PasswordResetRequestRepository.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/auth/PasswordResetRequestRepository.java new file mode 100644 index 000000000..567bfa701 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/auth/PasswordResetRequestRepository.java @@ -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 findByUserIdAndConsumedAtIsNullAndExpiresAtAfterOrderByCreatedAtDesc( + String userId, + Instant now + ); +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/auth/package-info.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/auth/package-info.java new file mode 100644 index 000000000..3080fb67f --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/auth/package-info.java @@ -0,0 +1,4 @@ +/** + * Password-reset domain entities and repository contracts. + */ +package com.iflytek.skillhub.domain.auth; diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewPermissionChecker.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewPermissionChecker.java index bc5dae42c..c3c125b13 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewPermissionChecker.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewPermissionChecker.java @@ -28,7 +28,8 @@ public class ReviewPermissionChecker { Map userNamespaceRoles, Set 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 userNamespaceRoles) { + if (namespaceType == NamespaceType.GLOBAL) { + return false; + } + + NamespaceRole role = userNamespaceRoles.get(namespaceId); + return role == NamespaceRole.OWNER || role == NamespaceRole.ADMIN; + } } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewService.java index bd31218a4..56ea78845 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewService.java @@ -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); } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/security/SecurityScanService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/security/SecurityScanService.java index 195510a5e..fb24451f1 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/security/SecurityScanService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/security/SecurityScanService.java @@ -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); } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillVersionStatus.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillVersionStatus.java index 78fa2bb16..21978985b 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillVersionStatus.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillVersionStatus.java @@ -4,6 +4,7 @@ public enum SkillVersionStatus { DRAFT, SCANNING, SCAN_FAILED, + UPLOADED, PENDING_REVIEW, PUBLISHED, REJECTED, diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/VisibilityChecker.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/VisibilityChecker.java index f46321595..65c8e7538 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/VisibilityChecker.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/VisibilityChecker.java @@ -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 userNamespaceRoles) { + return canAccess(skill, currentUserId, userNamespaceRoles, Set.of()); + } + + public boolean canAccess(Skill skill, String currentUserId, Map userNamespaceRoles, Set 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 platformRoles) { + return platformRoles != null && platformRoles.contains("SUPER_ADMIN"); + } } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadService.java index 259d9e18c..3bb194ff3 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillDownloadService.java @@ -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()); } } } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillGovernanceService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillGovernanceService.java index 9f0be2bf7..0dfcb5f35 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillGovernanceService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillGovernanceService.java @@ -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); diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillPublishService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillPublishService.java index 1355fc2d3..4cf72082d 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillPublishService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillPublishService.java @@ -132,7 +132,18 @@ public class SkillPublishService { String publisherId, SkillVisibility visibility, java.util.Set 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 entries, + String publisherId, + SkillVisibility visibility, + java.util.Set 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 userNamespaceRoles) { + Map 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 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 platformRoles, + boolean confirmWarnings, boolean forceAutoPublish, boolean bypassMembershipCheck) { @@ -225,18 +242,31 @@ public class SkillPublishService { "error.skill.publish.precheck.failed", String.join(", ", prePublishValidation.errors())); } + List 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 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 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 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()); diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillQueryService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillQueryService.java index 376f43b32..8bf887675 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillQueryService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillQueryService.java @@ -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 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 userNsRoles, + Set 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 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; } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillReviewSubmitService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillReviewSubmitService.java new file mode 100644 index 000000000..df8e9fd10 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillReviewSubmitService.java @@ -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. + * + *

This service handles two key workflows for UPLOADED skill versions: + *

    + *
  • submitForReview: Transitions an UPLOADED version to PENDING_REVIEW status, + * creating a review task for PUBLIC/NAMESPACE_ONLY visibility changes.
  • + *
  • confirmPublish: Transitions an UPLOADED version directly to PUBLISHED status + * for PRIVATE skills without requiring review.
  • + *
+ * + * @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. + * + *

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 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. + * + *

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 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 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"); + } + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/validation/BasicPrePublishValidator.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/validation/BasicPrePublishValidator.java index 3709bece4..19473d617 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/validation/BasicPrePublishValidator.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/validation/BasicPrePublishValidator.java @@ -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 errors = new ArrayList<>(); + List 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) {} } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/validation/SkillPackagePolicy.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/validation/SkillPackagePolicy.java index 67d055b95..7cd86dc58 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/validation/SkillPackagePolicy.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/validation/SkillPackagePolicy.java @@ -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") diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/validation/SkillPackageValidator.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/validation/SkillPackageValidator.java index 836a9eb5c..fb2b7a41f 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/validation/SkillPackageValidator.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/validation/SkillPackageValidator.java @@ -49,6 +49,7 @@ public class SkillPackageValidator { public ValidationResult validate(List entries) { List errors = new ArrayList<>(); + List warnings = new ArrayList<>(); Set 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) { diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/validation/ValidationResult.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/validation/ValidationResult.java index 5ec259c32..367f9812c 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/validation/ValidationResult.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/validation/ValidationResult.java @@ -4,17 +4,32 @@ import java.util.List; public record ValidationResult( boolean passed, - List errors + List errors, + List warnings ) { public static ValidationResult pass() { - return new ValidationResult(true, List.of()); + return new ValidationResult(true, List.of(), List.of()); } public static ValidationResult fail(List 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 warnings) { + return new ValidationResult(true, List.of(), List.copyOf(warnings)); + } + + public static ValidationResult of(List errors, List warnings) { + List safeErrors = errors == null ? List.of() : List.copyOf(errors); + List safeWarnings = warnings == null ? List.of() : List.copyOf(warnings); + return new ValidationResult(safeErrors.isEmpty(), safeErrors, safeWarnings); + } + + public boolean hasWarnings() { + return !warnings.isEmpty(); } } diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/ReviewPermissionCheckerTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/ReviewPermissionCheckerTest.java index 21fc18dc1..25ae2f704 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/ReviewPermissionCheckerTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/ReviewPermissionCheckerTest.java @@ -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"); diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/ReviewServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/ReviewServiceTest.java index 90246ed1b..f4fde3396 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/ReviewServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/ReviewServiceTest.java @@ -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(); diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/VisibilityCheckerTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/VisibilityCheckerTest.java index 1ffe5c8f3..af26901a0 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/VisibilityCheckerTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/VisibilityCheckerTest.java @@ -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); + } } diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillGovernanceServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillGovernanceServiceTest.java index f2b254fa6..b6f3faff4 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillGovernanceServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillGovernanceServiceTest.java @@ -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()); diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillPublishServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillPublishServiceTest.java index 8bc06d70f..caf6fe0cd 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillPublishServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillPublishServiceTest.java @@ -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 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 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 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); } diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillQueryServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillQueryServiceTest.java index a8c8042f8..7ee683ac7 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillQueryServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillQueryServiceTest.java @@ -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 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 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 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 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 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 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 result = service.listVersions( diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillReviewSubmitServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillReviewSubmitServiceTest.java new file mode 100644 index 000000000..4f8f9565c --- /dev/null +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillReviewSubmitServiceTest.java @@ -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 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 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); + } + } +} diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/validation/BasicPrePublishValidatorTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/validation/BasicPrePublishValidatorTest.java index f5f2ad00e..f428559cd 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/validation/BasicPrePublishValidatorTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/validation/BasicPrePublishValidatorTest.java @@ -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"))); - } } diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/validation/SkillPackageValidatorTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/validation/SkillPackageValidatorTest.java index 39de46407..4f74edaab 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/validation/SkillPackageValidatorTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/validation/SkillPackageValidatorTest.java @@ -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 diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/PasswordResetRequestJpaRepository.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/PasswordResetRequestJpaRepository.java new file mode 100644 index 000000000..a69702b0e --- /dev/null +++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/PasswordResetRequestJpaRepository.java @@ -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, PasswordResetRequestRepository { + + List findByUserIdAndConsumedAtIsNullAndExpiresAtAfterOrderByCreatedAtDesc( + String userId, + Instant now + ); +} diff --git a/server/skillhub-storage/src/main/java/com/iflytek/skillhub/storage/S3StorageService.java b/server/skillhub-storage/src/main/java/com/iflytek/skillhub/storage/S3StorageService.java index a0efbbd06..8e5452b52 100644 --- a/server/skillhub-storage/src/main/java/com/iflytek/skillhub/storage/S3StorageService.java +++ b/server/skillhub-storage/src/main/java/com/iflytek/skillhub/storage/S3StorageService.java @@ -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 keys) { if (keys.isEmpty()) return; try { + ensureBucketPrepared(); List 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) { diff --git a/server/skillhub-storage/src/test/java/com/iflytek/skillhub/storage/S3StorageServiceTest.java b/server/skillhub-storage/src/test/java/com/iflytek/skillhub/storage/S3StorageServiceTest.java index 8ca8cae70..0fa1887ce 100644 --- a/server/skillhub-storage/src/test/java/com/iflytek/skillhub/storage/S3StorageServiceTest.java +++ b/server/skillhub-storage/src/test/java/com/iflytek/skillhub/storage/S3StorageServiceTest.java @@ -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; + } + } } diff --git a/web/e2e/helpers/auth-fixtures.ts b/web/e2e/helpers/auth-fixtures.ts index 31a9fb4af..fc89c8a1c 100644 --- a/web/e2e/helpers/auth-fixtures.ts +++ b/web/e2e/helpers/auth-fixtures.ts @@ -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}`, + }) +} diff --git a/web/e2e/helpers/review-seed.ts b/web/e2e/helpers/review-seed.ts new file mode 100644 index 000000000..683ad2b5d --- /dev/null +++ b/web/e2e/helpers/review-seed.ts @@ -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 Promise }> { + 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() + }, + } +} diff --git a/web/e2e/helpers/session.ts b/web/e2e/helpers/session.ts index 6b6ee60ff..763ef1c37 100644 --- a/web/e2e/helpers/session.ts +++ b/web/e2e/helpers/session.ts @@ -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) { diff --git a/web/e2e/helpers/test-data-builder.ts b/web/e2e/helpers/test-data-builder.ts index 7d7d4e975..0123468ba 100644 --- a/web/e2e/helpers/test-data-builder.ts +++ b/web/e2e/helpers/test-data-builder.ts @@ -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 { 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 { + await new Promise((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 { 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 { + if (!this.isTeamNamespace(namespace)) { + return null + } + + if (namespace.status === 'FROZEN' && namespace.canUnfreeze) { + return parseEnvelope( + await this.request.post(`/api/web/namespaces/${encodeURIComponent(namespace.slug)}/unfreeze`), + ) + } + + if (namespace.status === 'ARCHIVED' && namespace.canRestore) { + return parseEnvelope( + await this.request.post(`/api/web/namespaces/${encodeURIComponent(namespace.slug)}/restore`), + ) + } + + return null + } + async ensureWritableNamespace(): Promise { 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 { + 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 { @@ -350,6 +473,21 @@ export class E2eTestDataBuilder { ) } + async searchNamespaceMemberCandidates(slug: string, search: string): Promise { + const query = new URLSearchParams({ search }) + return parseEnvelope( + 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 { + 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 { 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 { - const namespace = await this.ensureWritableNamespace() + const namespace = await this.ensureReviewableNamespace() const skill = await this.publishSkill(namespace.slug) return { namespace, skill } } diff --git a/web/e2e/namespace-review-detail-access.spec.ts b/web/e2e/namespace-review-detail-access.spec.ts new file mode 100644 index 000000000..1daac06d9 --- /dev/null +++ b/web/e2e/namespace-review-detail-access.spec.ts @@ -0,0 +1,59 @@ +import { expect, test } from '@playwright/test' +import { setEnglishLocale } from './helpers/auth-fixtures' +import { createNamespaceReviewData } from './helpers/review-seed' + +test.describe('Namespace Review Detail Access (Real API)', () => { + test.describe.configure({ timeout: 120_000 }) + + test.beforeEach(async ({ page }) => { + await setEnglishLocale(page) + }) + + test('opens namespace review detail from the namespace review list', async ({ browser, page }, testInfo) => { + let seeded: Awaited> | undefined + try { + seeded = await createNamespaceReviewData(browser, page, testInfo) + + await page.goto(`/dashboard/namespaces/${seeded.namespace.slug}/reviews`) + + await expect(page.getByRole('heading', { name: 'Namespace Reviews' })).toBeVisible() + await expect(page.getByText(`${seeded.namespace.slug}/${seeded.skill.slug}`)).toBeVisible() + + await page.getByRole('link', { name: 'Open review' }).first().click() + + await expect(page).toHaveURL(new RegExp(`/dashboard/namespaces/${seeded.namespace.slug}/reviews/\\d+$`)) + await expect(page.getByRole('heading', { name: 'Review Detail' })).toBeVisible() + await expect(page.getByText(`${seeded.namespace.slug}/${seeded.skill.slug}`).first()).toBeVisible() + } finally { + await seeded?.cleanup() + } + }) + + test('redirects /dashboard/reviews to a namespace review page for namespace operators', async ({ browser, page }, testInfo) => { + let seeded: Awaited> | undefined + try { + seeded = await createNamespaceReviewData(browser, page, testInfo) + + await page.goto('/dashboard/reviews') + + await expect(page).toHaveURL(/\/dashboard\/namespaces\/.+\/reviews$/) + await expect(page.getByRole('heading', { name: 'Namespace Reviews' })).toBeVisible() + } finally { + await seeded?.cleanup() + } + }) + + test('redirects namespace review detail opened through the global detail route', async ({ browser, page }, testInfo) => { + let seeded: Awaited> | undefined + try { + seeded = await createNamespaceReviewData(browser, page, testInfo) + + await page.goto(`/dashboard/reviews/${seeded.reviewTaskId}`) + + await expect(page).toHaveURL(new RegExp(`/dashboard/namespaces/${seeded.namespace.slug}/reviews/${seeded.reviewTaskId}$`)) + await expect(page.getByRole('heading', { name: 'Review Detail' })).toBeVisible() + } finally { + await seeded?.cleanup() + } + }) +}) diff --git a/web/e2e/namespace-reviews-data.spec.ts b/web/e2e/namespace-reviews-data.spec.ts index a173ecaae..b2cad5c6a 100644 --- a/web/e2e/namespace-reviews-data.spec.ts +++ b/web/e2e/namespace-reviews-data.spec.ts @@ -1,26 +1,24 @@ import { expect, test } from '@playwright/test' import { setEnglishLocale } from './helpers/auth-fixtures' -import { registerSession } from './helpers/session' -import { E2eTestDataBuilder } from './helpers/test-data-builder' +import { createNamespaceReviewData } from './helpers/review-seed' test.describe('Namespace Reviews Data (Real API)', () => { - test.beforeEach(async ({ page }, testInfo) => { + test.describe.configure({ timeout: 120_000 }) + + test.beforeEach(async ({ page }) => { await setEnglishLocale(page) - await registerSession(page, testInfo) }) - test('opens namespace reviews page with seeded review data context', async ({ page }, testInfo) => { - const builder = new E2eTestDataBuilder(page, testInfo) - await builder.init() - + test('opens namespace reviews page with seeded review data context', async ({ browser, page }, testInfo) => { + let seeded: Awaited> | undefined try { - const seeded = await builder.createReviewData() + seeded = await createNamespaceReviewData(browser, page, testInfo) await page.goto(`/dashboard/namespaces/${seeded.namespace.slug}/reviews`) await expect(page.getByRole('heading', { name: 'Namespace Reviews' })).toBeVisible() await expect(page.getByText(`Review tasks for ${seeded.namespace.displayName}`)).toBeVisible() } finally { - await builder.cleanup() + await seeded?.cleanup() } }) }) diff --git a/web/e2e/password-reset.spec.ts b/web/e2e/password-reset.spec.ts new file mode 100644 index 000000000..a37efcf5e --- /dev/null +++ b/web/e2e/password-reset.spec.ts @@ -0,0 +1,45 @@ +import { expect, test } from '@playwright/test' +import { setEnglishLocale, setUniqueClientIp } from './helpers/auth-fixtures' + +test.describe('Password Reset (Real API)', () => { + function uniqueResetEmail(seed: string) { + return `nonexistent_${seed}_${Date.now()}@example.com` + } + + test.beforeEach(async ({ page }) => { + await setEnglishLocale(page) + }) + + test('sends verification code from reset-password page', async ({ page }) => { + const email = uniqueResetEmail('request') + await setUniqueClientIp(page, 'password-reset-request') + + await page.goto('/reset-password') + + await expect(page.getByRole('heading', { name: 'Reset Password' })).toBeVisible() + await page.getByLabel('Email').fill(email) + await page.getByRole('button', { name: 'Send Verification Code' }).click() + + await expect(page.getByText('If the account is eligible, a verification code has been sent.')).toBeVisible() + }) + + test('shows backend validation error for an invalid reset code', async ({ page }) => { + const email = uniqueResetEmail('invalid-code') + await setUniqueClientIp(page, 'password-reset-invalid-code') + + await page.goto('/reset-password') + + await expect(page.getByRole('heading', { name: 'Reset Password' })).toBeVisible() + await page.getByLabel('Email').fill(email) + await page.getByRole('button', { name: 'Send Verification Code' }).click() + await expect(page.getByText('If the account is eligible, a verification code has been sent.')).toBeVisible() + await page.getByLabel('Verification Code').fill('123456') + await page.getByLabel('New Password').fill('Passw0rd!123') + await page.getByLabel('Confirm Password').fill('Passw0rd!123') + await page.getByRole('button', { name: 'Reset Password' }).click() + + await expect( + page.getByText(/The verification code is invalid or has expired\.|验证码无效或已过期。/) + ).toBeVisible() + }) +}) diff --git a/web/e2e/public-skill-detail-anonymous.spec.ts b/web/e2e/public-skill-detail-anonymous.spec.ts new file mode 100644 index 000000000..56ba8885a --- /dev/null +++ b/web/e2e/public-skill-detail-anonymous.spec.ts @@ -0,0 +1,46 @@ +import { expect, test } from '@playwright/test' +import { setEnglishLocale } from './helpers/auth-fixtures' +import { getSearchCard, prepareSearchSeed, type PreparedSearchSeed } from './helpers/search-seed' + +const SEARCH_URL = (q: string) => `/search?q=${encodeURIComponent(q)}&sort=relevance&page=0&starredOnly=false` + +function latestSeed(seed: PreparedSearchSeed) { + return { + skill: seed.skills[seed.skills.length - 1], + skillName: seed.skillNames[seed.skillNames.length - 1], + } +} + +let seeded: PreparedSearchSeed | undefined + +test.describe('Public Skill Detail Anonymous Access (Real API)', () => { + test.beforeAll(async ({ browser }, testInfo) => { + seeded = await prepareSearchSeed(browser, testInfo, { count: 1 }) + }) + + test.afterAll(async () => { + await seeded?.dispose() + seeded = undefined + }) + + test.beforeEach(async ({ page }) => { + await setEnglishLocale(page) + }) + + test('allows anonymous users to open a public skill detail and view install content', async ({ page }) => { + const current = latestSeed(seeded!) + + await page.goto(SEARCH_URL(seeded!.keyword)) + const card = getSearchCard(page, current.skillName) + await expect(card).toBeVisible({ timeout: 15_000 }) + + await card.click() + + await expect(page).toHaveURL(new RegExp(`/space/${current.skill.namespace}/${current.skill.slug}$`)) + await expect(page).not.toHaveURL(/\/login\?returnTo=/) + await expect(page.getByRole('heading', { name: current.skillName, exact: true })).toBeVisible() + await expect(page.getByText('Install', { exact: true })).toBeVisible() + await expect(page.getByText(new RegExp(`npx clawhub install ${current.skill.slug}`))).toBeVisible() + await expect(page.getByRole('button', { name: 'Copy' }).first()).toBeVisible() + }) +}) diff --git a/web/e2e/publish-flow-ui.spec.ts b/web/e2e/publish-flow-ui.spec.ts index a1f1ef76a..867c8248b 100644 --- a/web/e2e/publish-flow-ui.spec.ts +++ b/web/e2e/publish-flow-ui.spec.ts @@ -1,8 +1,19 @@ import { expect, test } from '@playwright/test' +import path from 'node:path' import { setEnglishLocale } from './helpers/auth-fixtures' import { registerSession } from './helpers/session' import { E2eTestDataBuilder } from './helpers/test-data-builder' +interface PublishEnvelope { + code: number + msg?: string + data: { + namespace: string + slug: string + version: string + } +} + test.describe('Publish Flow UI (Real API)', () => { test.beforeEach(async ({ page }, testInfo) => { await setEnglishLocale(page) @@ -15,19 +26,48 @@ test.describe('Publish Flow UI (Real API)', () => { try { const namespace = await builder.ensureWritableNamespace() - const packagePath = builder.createSkillPackageFile() + const skillName = `publish-ui-${Date.now().toString(36)}` + const packagePath = builder.createSkillPackageFile({ name: skillName }) await page.goto('/dashboard/publish') await expect(page.getByRole('heading', { name: 'Publish Skill' })).toBeVisible() - await page.locator('#namespace').click() - await page.getByText(new RegExp(`\\(@${namespace.slug}\\)`)).first().click() + const namespaceTrigger = page.locator('#namespace') + await expect(namespaceTrigger).toBeVisible() + await namespaceTrigger.click() + const namespaceOption = page.getByRole('option', { + name: new RegExp(`\\(@${namespace.slug}\\)`), + }).first() + await expect(namespaceOption).toBeVisible() + await namespaceOption.evaluate((element: HTMLElement) => { + element.scrollIntoView({ block: 'center' }) + element.click() + }) + await expect(namespaceTrigger).toContainText(`@${namespace.slug}`) await page.locator('input[type="file"]').setInputFiles(packagePath) - await page.getByRole('button', { name: 'Confirm Publish' }).click() + await expect(page.getByText(path.basename(packagePath))).toBeVisible() + const confirmButton = page.getByRole('button', { name: 'Confirm Publish' }) + await expect(confirmButton).toBeEnabled() + const publishResponsePromise = page.waitForResponse( + (response) => + response.request().method() === 'POST' + && response.url().includes(`/api/web/skills/${encodeURIComponent(namespace.slug)}/publish`), + { timeout: 90_000 }, + ) + await confirmButton.click() + const publishResponse = await publishResponsePromise + const publishBody = await publishResponse.json() as PublishEnvelope - await expect(page).toHaveURL('/dashboard/skills') - await expect(page.getByRole('heading', { name: 'My Skills' })).toBeVisible() + expect(publishResponse.status(), `publish failed: ${publishBody.msg ?? 'unknown error'}`).toBe(200) + expect(publishBody.code).toBe(0) + expect(publishBody.data.namespace).toBe(namespace.slug) + + await page.goto('/dashboard/skills') + await expect(page.getByRole('heading', { name: 'My Skills' })).toBeVisible({ timeout: 30_000 }) + await expect(page.getByRole('heading', { name: skillName, exact: true })).toBeVisible({ timeout: 30_000 }) + await expect(page.getByText(`@${publishBody.data.namespace}`).first()).toBeVisible() + await expect(page.getByText(`v${publishBody.data.version}`).first()).toBeVisible() } finally { await builder.cleanup() } diff --git a/web/e2e/register-email-required.spec.ts b/web/e2e/register-email-required.spec.ts new file mode 100644 index 000000000..54d2476a1 --- /dev/null +++ b/web/e2e/register-email-required.spec.ts @@ -0,0 +1,46 @@ +import { expect, test } from '@playwright/test' +import { setEnglishLocale } from './helpers/auth-fixtures' + +function buildUniqueUser() { + const suffix = `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 7)}` + return { + username: `e2e_reg_${suffix}`, + email: `e2e_reg_${suffix}@example.test`, + password: 'Passw0rd!123', + } +} + +test.describe('Register Email Required (Real API)', () => { + test.beforeEach(async ({ page }) => { + await setEnglishLocale(page) + }) + + test('registers successfully when email is provided', async ({ page }) => { + const user = buildUniqueUser() + await page.goto('/register') + + await expect(page.getByRole('heading', { name: 'Create Account' })).toBeVisible() + await page.getByLabel('Username').fill(user.username) + await page.getByLabel('Email').fill(user.email) + await page.getByLabel('Password').fill(user.password) + await page.getByRole('button', { name: 'Register & Login' }).click() + + await expect(page).toHaveURL('/dashboard') + }) + + test('shows required validation when email is missing', async ({ page }) => { + await page.goto('/register') + + await page.getByLabel('Username').fill(`e2e_no_email_${Date.now().toString(36)}`) + await page.getByLabel('Password').fill('Passw0rd!123') + await page.getByRole('button', { name: 'Register & Login' }).click() + + const isEmailMissing = await page.getByLabel('Email').evaluate((element) => { + const input = element as HTMLInputElement + return input.validity.valueMissing + }) + + expect(isEmailMissing).toBeTruthy() + await expect(page).toHaveURL(/\/register/) + }) +}) diff --git a/web/e2e/register-login-validation.spec.ts b/web/e2e/register-login-validation.spec.ts index 2d0c85310..7ab8574ab 100644 --- a/web/e2e/register-login-validation.spec.ts +++ b/web/e2e/register-login-validation.spec.ts @@ -1,5 +1,5 @@ import { expect, test } from '@playwright/test' -import { setEnglishLocale } from './helpers/auth-fixtures' +import { setEnglishLocale, setUniqueClientIp } from './helpers/auth-fixtures' import { createFreshSession } from './helpers/session' // TC_UN_* 用户名输入框 / TC_EM_* 邮箱输入框 / TC_PW_* 密码输入框 @@ -10,14 +10,16 @@ const DUPLICATE_USERNAME_ERROR = /already.*exist|taken|username.*used/i const REGISTER_RATE_LIMIT_ERROR = /too many|too frequent|rate limit|请求过于频繁/ test.describe('Register - Username Validation (Real API)', () => { - test.beforeEach(async ({ page }) => { + test.beforeEach(async ({ page }, testInfo) => { await setEnglishLocale(page) + await setUniqueClientIp(page, `register-validation-${testInfo.title}`) await page.goto('/register') }) // TC_UN_008 P0 test('TC_UN_008: shows required error when username is empty', async ({ page }) => { - await page.getByRole('button', { name: 'Register' }).click() + await page.getByLabel(/username/i).click() + await page.getByLabel(/email/i).click() await expect(page.getByText(/username.*required|required.*username/i)).toBeVisible() }) @@ -51,18 +53,19 @@ test.describe('Register - Username Validation (Real API)', () => { }) test.describe('Register - Email Validation (Real API)', () => { - test.beforeEach(async ({ page }) => { + test.beforeEach(async ({ page }, testInfo) => { await setEnglishLocale(page) + await setUniqueClientIp(page, `register-validation-${testInfo.title}`) await page.goto('/register') }) - // TC_EM_007 P0 - email is optional - test('TC_EM_007: allows empty email (email is optional)', async ({ page }) => { + // TC_EM_007 P0 - email is required + test('TC_EM_007: shows required error when email is empty', async ({ page }) => { const emailField = page.getByLabel(/email/i) if (await emailField.isVisible()) { await emailField.clear() await emailField.blur() - await expect(page.getByText(/email.*required/i)).not.toBeVisible() + await expect(page.getByText(/email.*required|required.*email/i)).toBeVisible() } }) @@ -88,14 +91,16 @@ test.describe('Register - Email Validation (Real API)', () => { }) test.describe('Register - Password Validation (Real API)', () => { - test.beforeEach(async ({ page }) => { + test.beforeEach(async ({ page }, testInfo) => { await setEnglishLocale(page) + await setUniqueClientIp(page, `register-validation-${testInfo.title}`) await page.goto('/register') }) // TC_PW_013 P0 - empty password test('TC_PW_013: shows required error when password is empty', async ({ page }) => { - await page.getByRole('button', { name: 'Register' }).click() + await page.getByLabel(/^password/i).click() + await page.getByLabel(/username/i).click() await expect(page.getByText(/password.*required|required.*password/i)).toBeVisible() }) @@ -123,8 +128,9 @@ test.describe('Register - Password Validation (Real API)', () => { }) test.describe('Register Flow (Real API)', () => { - test.beforeEach(async ({ page }) => { + test.beforeEach(async ({ page }, testInfo) => { await setEnglishLocale(page) + await setUniqueClientIp(page, `register-validation-${testInfo.title}`) }) // TC_REG_001 P0 - successful registration with all fields @@ -138,7 +144,7 @@ test.describe('Register Flow (Real API)', () => { await emailField.fill(`test_${suffix}@example.test`) } await page.getByLabel(/^password/i).fill('Test123!@') - await page.getByRole('button', { name: 'Register' }).click() + await page.getByRole('button', { name: 'Register & Login' }).click() // Should redirect away from /register on success await expect(page).not.toHaveURL('/register') existingRegisteredUsername = username @@ -160,13 +166,14 @@ test.describe('Register Flow (Real API)', () => { await page.goto('/register') await setEnglishLocale(page) await page.getByLabel(/username/i).fill(username) + await page.getByLabel(/email/i).fill(`duplicate_${Date.now()}@example.test`) await page.getByLabel(/^password/i).fill('Test123!@') const main = page.getByRole('main') const duplicateUsernameError = main.getByText(DUPLICATE_USERNAME_ERROR).first() const registerRateLimitError = main.getByText(REGISTER_RATE_LIMIT_ERROR).first() for (let attempt = 0; attempt < 3; attempt += 1) { - await page.getByRole('button', { name: 'Register' }).click() + await page.getByRole('button', { name: 'Register & Login' }).click() if (await duplicateUsernameError.isVisible().catch(() => false)) { return @@ -184,27 +191,35 @@ test.describe('Register Flow (Real API)', () => { }) // TC_REG_002 P0 - registration without email - test('TC_REG_002: registers successfully without email (email is optional)', async ({ page }) => { + test('TC_REG_002: shows required validation when email is missing', async ({ page }) => { await page.goto('/register') const suffix = Date.now().toString(36) + Math.random().toString(36).slice(2, 5) - await page.getByLabel(/username/i).fill(`noemail_${suffix}`) + await page.getByLabel(/username/i).fill(`emailrequired_${suffix}`) await page.getByLabel(/^password/i).fill('Test123!@') - await page.getByRole('button', { name: 'Register' }).click() - await expect(page).not.toHaveURL('/register') + await page.getByLabel(/email/i).click() + await page.getByLabel(/username/i).click() + await page.getByRole('button', { name: 'Register & Login' }).click() + const emailField = page.getByLabel(/email/i) + await expect(emailField).toBeFocused() + await expect + .poll(async () => emailField.evaluate((input) => (input as HTMLInputElement).validity.valueMissing)) + .toBe(true) }) // TC_REG_005 P0 - required fields empty on submit test('TC_REG_005: shows validation errors when submitting empty required fields', async ({ page }) => { await page.goto('/register') - await page.getByRole('button', { name: 'Register' }).click() + await page.getByLabel(/email/i).fill(`required_fields_${Date.now()}@example.test`) + await page.getByRole('button', { name: 'Register & Login' }).click() await expect(page.getByText(/username.*required|required.*username/i)).toBeVisible() await expect(page.getByText(/password.*required|required.*password/i)).toBeVisible() }) }) test.describe('Login Flow (Real API)', () => { - test.beforeEach(async ({ page }) => { + test.beforeEach(async ({ page }, testInfo) => { await setEnglishLocale(page) + await setUniqueClientIp(page, `register-validation-${testInfo.title}`) }) // TC_REG_006 P0 - successful login (already tested in auth-entry.spec.ts partially; extend here) @@ -223,8 +238,9 @@ test.describe('Login Flow (Real API)', () => { await page.goto('/register') await page.getByLabel(/username/i).fill(username) + await page.getByLabel(/email/i).fill(`login_${suffix}@example.test`) await page.getByLabel(/^password/i).fill('Test123!@') - await page.getByRole('button', { name: 'Register' }).click() + await page.getByRole('button', { name: 'Register & Login' }).click() await expect(page).not.toHaveURL('/register') await page.goto('/login') diff --git a/web/e2e/search-card-interaction.spec.ts b/web/e2e/search-card-interaction.spec.ts index 3fca443a5..1deadc585 100644 --- a/web/e2e/search-card-interaction.spec.ts +++ b/web/e2e/search-card-interaction.spec.ts @@ -30,38 +30,61 @@ async function waitForCards(page: Page) { const keyword = basicSeed?.keyword const encodedKeyword = keyword ? encodeURIComponent(keyword) : null + let reloaded = false - for (let attempt = 0; attempt < 4; attempt += 1) { - await page.waitForLoadState('networkidle') + const waitForMatchingResponse = async () => { + if (!encodedKeyword) { + return + } + + await page.waitForResponse(async (response) => { + if (!response.url().includes('/api/web/skills?') || !response.url().includes(`q=${encodedKeyword}`)) { + return false + } + if (response.status() !== 200) { + return false + } + + try { + const payload = await response.json() as { data?: { items?: Array } } + return Array.isArray(payload.data?.items) && payload.data.items.length > 0 + } catch { + return false + } + }, { timeout: 15_000 }).catch(() => null) + } + + const waitForCardCount = async () => { + await expect.poll( + async () => cards.count(), + { + timeout: 20_000, + intervals: [250, 500, 1_000, 2_000], + }, + ).toBeGreaterThan(0) + } + + await page.waitForLoadState('networkidle') + await expect(page.getByRole('textbox', { name: 'Search skills...' })).toBeVisible({ timeout: 8_000 }) + + if (await cards.count() > 0) { + return cards + } + + await waitForMatchingResponse() + + try { + await waitForCardCount() + } catch { + if (reloaded) { + throw new Error('Timed out waiting for search cards after one reload fallback') + } + + reloaded = true + await page.reload({ waitUntil: 'networkidle' }) await expect(page.getByRole('textbox', { name: 'Search skills...' })).toBeVisible({ timeout: 8_000 }) - - if (await cards.count() > 0) { - return cards - } - - if (attempt < 3) { - const responsePromise = encodedKeyword - ? page.waitForResponse(async (response) => { - if (!response.url().includes('/api/web/skills?') || !response.url().includes(`q=${encodedKeyword}`)) { - return false - } - if (response.status() !== 200) { - return false - } - - try { - const payload = await response.json() as { data?: { items?: Array } } - return Array.isArray(payload.data?.items) && payload.data.items.length > 0 - } catch { - return false - } - }, { timeout: 12_000 }).catch(() => null) - : Promise.resolve(null) - - await page.waitForTimeout(750 * (attempt + 1)) - await page.reload({ waitUntil: 'networkidle' }) - await responsePromise - } + await waitForMatchingResponse() + await waitForCardCount() } return cards diff --git a/web/e2e/search-page-full.spec.ts b/web/e2e/search-page-full.spec.ts index f5775bfb9..be14db8fd 100644 --- a/web/e2e/search-page-full.spec.ts +++ b/web/e2e/search-page-full.spec.ts @@ -39,12 +39,11 @@ test.describe('Search Input (Real API)', () => { await expect(getSearchCards(page).first()).toBeVisible({ timeout: 10_000 }) }) - // TC_SEARCH_INPUT_003 P0 - empty search guidance - test('TC_SEARCH_INPUT_003: empty search shows keyword guidance instead of a default list', async ({ page }) => { + // TC_SEARCH_INPUT_003 P0 - empty search shows the default discovery list + test('TC_SEARCH_INPUT_003: empty search shows the default discovery list', async ({ page }) => { await page.goto(searchUrl('')) await expect(page).toHaveURL(/\/search/) - await expect(page.getByRole('heading', { name: 'No results found' })).toBeVisible() - await expect(page.getByText('Please enter a search keyword')).toBeVisible() + await expect(getSearchCards(page).first()).toBeVisible({ timeout: 10_000 }) }) // TC_SEARCH_INPUT_004 P0 - Enter key triggers search diff --git a/web/e2e/settings-pages.spec.ts b/web/e2e/settings-pages.spec.ts index 8c77919cd..de2abd6ec 100644 --- a/web/e2e/settings-pages.spec.ts +++ b/web/e2e/settings-pages.spec.ts @@ -13,6 +13,13 @@ test.describe('Settings Pages (Real API)', () => { await expect(page.getByRole('heading', { name: 'Profile Settings' })).toBeVisible() }) + test('navigates to reset-password page from profile settings', async ({ page }) => { + await page.goto('/settings/profile') + await page.getByRole('button', { name: 'Reset Password' }).click() + await expect(page).toHaveURL('/reset-password') + await expect(page.getByRole('heading', { name: 'Reset Password' })).toBeVisible() + }) + test('shows validation when current password is missing', async ({ page }) => { await page.goto('/settings/security') await expect(page.getByRole('heading', { name: 'Security Settings' })).toBeVisible() diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 928036a0a..1e7351ff9 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -2,6 +2,8 @@ import createClient from 'openapi-fetch' import type { paths } from './generated/schema' import type { ChangePasswordRequest, + PasswordResetConfirmRequest, + PasswordResetRequest, ApiToken, CreateTokenRequest, CreateTokenResponse, @@ -354,6 +356,26 @@ export const authApi = { }) }, + async requestPasswordReset(request: PasswordResetRequest): Promise { + await fetchJson('/api/v1/auth/local/password-reset/request', { + method: 'POST', + headers: await ensureCsrfHeaders({ + 'Content-Type': 'application/json', + }), + body: JSON.stringify(request), + }) + }, + + async confirmPasswordReset(request: PasswordResetConfirmRequest): Promise { + await fetchJson('/api/v1/auth/local/password-reset/confirm', { + method: 'POST', + headers: await ensureCsrfHeaders({ + 'Content-Type': 'application/json', + }), + body: JSON.stringify(request), + }) + }, + async logout(): Promise { const response = await fetch('/api/v1/auth/logout', { method: 'POST', @@ -466,14 +488,44 @@ export const skillLifecycleApi = { }) }, - async rereleaseVersion(namespace: string, slug: string, version: string, targetVersion: string): Promise { + async rereleaseVersion(namespace: string, slug: string, version: string, targetVersion: string, confirmWarnings = false): Promise { const cleanNamespace = namespace.startsWith('@') ? namespace.slice(1) : namespace await fetchJson(`${WEB_API_PREFIX}/skills/${cleanNamespace}/${encodeURIComponent(slug)}/versions/${encodeURIComponent(version)}/rerelease`, { method: 'POST', headers: await ensureCsrfHeaders({ 'Content-Type': 'application/json', }), - body: JSON.stringify({ targetVersion }), + body: JSON.stringify({ targetVersion, confirmWarnings }), + }) + }, + + /** + * Submit an UPLOADED version for review. + * Transitions version status from UPLOADED to PENDING_REVIEW. + */ + async submitForReview(namespace: string, slug: string, version: string, targetVisibility: 'PUBLIC' | 'NAMESPACE_ONLY'): Promise { + const cleanNamespace = namespace.startsWith('@') ? namespace.slice(1) : namespace + await fetchJson(`${WEB_API_PREFIX}/skills/${cleanNamespace}/${encodeURIComponent(slug)}/submit-review`, { + method: 'POST', + headers: await ensureCsrfHeaders({ + 'Content-Type': 'application/json', + }), + body: JSON.stringify({ version, targetVisibility }), + }) + }, + + /** + * Confirm publish for a PRIVATE skill version. + * Transitions version status from UPLOADED to PUBLISHED without review. + */ + async confirmPublish(namespace: string, slug: string, version: string): Promise { + const cleanNamespace = namespace.startsWith('@') ? namespace.slice(1) : namespace + await fetchJson(`${WEB_API_PREFIX}/skills/${cleanNamespace}/${encodeURIComponent(slug)}/confirm-publish`, { + method: 'POST', + headers: await ensureCsrfHeaders({ + 'Content-Type': 'application/json', + }), + body: JSON.stringify({ version }), }) }, } @@ -1063,6 +1115,13 @@ export const adminApi = { }) }, + async triggerPasswordReset(userId: string): Promise { + await fetchJson(`/api/v1/admin/users/${userId}/password-reset`, { + method: 'POST', + headers: getCsrfHeaders(), + }) + }, + async getAuditLogs(params: { action?: string userId?: string diff --git a/web/src/api/generated/schema.d.ts b/web/src/api/generated/schema.d.ts index 5da2df9da..d87140256 100644 --- a/web/src/api/generated/schema.d.ts +++ b/web/src/api/generated/schema.d.ts @@ -1140,6 +1140,38 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v1/auth/local/password-reset/request": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["requestPasswordReset"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/auth/local/password-reset/confirm": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["confirmPasswordReset"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/auth/local/login": { parameters: { query?: never; @@ -1220,6 +1252,22 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v1/admin/users/{userId}/password-reset": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["triggerPasswordReset"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/admin/users/{userId}/enable": { parameters: { query?: never; @@ -3189,6 +3237,7 @@ export interface components { }; SkillVersionRereleaseRequest: { targetVersion: string; + confirmWarnings?: boolean; }; SkillReportSubmitRequest: { reason?: string; @@ -3413,7 +3462,15 @@ export interface components { LocalRegisterRequest: { username: string; password: string; - email?: string; + email: string; + }; + PasswordResetRequestDto: { + email: string; + }; + PasswordResetConfirmRequest: { + email: string; + code: string; + newPassword: string; }; LocalLoginRequest: { username: string; @@ -5624,6 +5681,7 @@ export interface operations { parameters: { query: { visibility: string; + confirmWarnings?: boolean; }; header?: never; path: { @@ -5655,6 +5713,7 @@ export interface operations { parameters: { query: { visibility: string; + confirmWarnings?: boolean; }; header?: never; path: { @@ -6678,6 +6737,7 @@ export interface operations { query: { payload: string; files: string[]; + confirmWarnings?: boolean; }; header?: never; path?: never; @@ -6722,6 +6782,7 @@ export interface operations { parameters: { query: { namespace: string; + confirmWarnings?: boolean; }; header?: never; path?: never; @@ -6819,6 +6880,54 @@ export interface operations { }; }; }; + requestPasswordReset: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["PasswordResetRequestDto"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseVoid"]; + }; + }; + }; + }; + confirmPasswordReset: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["PasswordResetConfirmRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseVoid"]; + }; + }; + }; + }; login: { parameters: { query?: never; @@ -6935,6 +7044,28 @@ export interface operations { }; }; }; + triggerPasswordReset: { + parameters: { + query?: never; + header?: never; + path: { + userId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseVoid"]; + }; + }; + }; + }; enableUser: { parameters: { query?: never; diff --git a/web/src/api/types.ts b/web/src/api/types.ts index 61c9fa46b..2b2f93823 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -53,7 +53,7 @@ export interface LocalLoginRequest { } export interface LocalRegisterRequest extends LocalLoginRequest { - email?: string + email: string } export interface ChangePasswordRequest { @@ -61,6 +61,16 @@ export interface ChangePasswordRequest { newPassword: string } +export interface PasswordResetRequest { + email: string +} + +export interface PasswordResetConfirmRequest { + email: string + code: string + newPassword: string +} + export type CreateNamespaceRequest = Omit & { slug: string displayName: string diff --git a/web/src/app/router.tsx b/web/src/app/router.tsx index fd48292b6..4095904d3 100644 --- a/web/src/app/router.tsx +++ b/web/src/app/router.tsx @@ -65,6 +65,7 @@ const LandingPage = createLazyRouteComponent(() => import('@/pages/landing'), 'L const HomePage = createLazyRouteComponent(() => import('@/pages/home'), 'HomePage') const LoginPage = createLazyRouteComponent(() => import('@/pages/login'), 'LoginPage') const RegisterPage = createLazyRouteComponent(() => import('@/pages/register'), 'RegisterPage') +const ResetPasswordPage = createLazyRouteComponent(() => import('@/pages/reset-password'), 'ResetPasswordPage') const PrivacyPolicyPage = createLazyRouteComponent(() => import('@/pages/privacy'), 'PrivacyPolicyPage') const SearchPage = createLazyRouteComponent(() => import('@/pages/search'), 'SearchPage') const TermsOfServicePage = createLazyRouteComponent(() => import('@/pages/terms'), 'TermsOfServicePage') @@ -85,22 +86,18 @@ const NamespaceReviewsPage = createLazyRouteComponent( () => import('@/pages/dashboard/namespace-reviews'), 'NamespaceReviewsPage', ) -const GovernancePage = createLazyRouteComponent(() => import('@/pages/dashboard/governance'), 'GovernancePage') -const ReviewsPage = createRoleProtectedRouteComponent( - () => import('@/pages/dashboard/reviews'), - 'ReviewsPage', - ['SKILL_ADMIN', 'NAMESPACE_ADMIN', 'USER_ADMIN', 'SUPER_ADMIN'], +const NamespaceReviewDetailPage = createLazyRouteComponent( + () => import('@/pages/dashboard/review-detail'), + 'NamespaceReviewDetailPage', ) +const GovernancePage = createLazyRouteComponent(() => import('@/pages/dashboard/governance'), 'GovernancePage') +const ReviewsPage = createLazyRouteComponent(() => import('@/pages/dashboard/reviews'), 'ReviewsPage') const ReportsPage = createRoleProtectedRouteComponent( () => import('@/pages/dashboard/reports'), 'ReportsPage', ['SKILL_ADMIN', 'SUPER_ADMIN'], ) -const ReviewDetailPage = createRoleProtectedRouteComponent( - () => import('@/pages/dashboard/review-detail'), - 'ReviewDetailPage', - ['SKILL_ADMIN', 'NAMESPACE_ADMIN', 'SUPER_ADMIN'], -) +const ReviewDetailPage = createLazyRouteComponent(() => import('@/pages/dashboard/review-detail'), 'ReviewDetailPage') const PromotionsPage = createRoleProtectedRouteComponent( () => import('@/pages/dashboard/promotions'), 'PromotionsPage', @@ -184,6 +181,12 @@ const registerRoute = createRoute({ component: RegisterPage, }) +const resetPasswordRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'reset-password', + component: ResetPasswordPage, +}) + const privacyRoute = createRoute({ getParentRoute: () => rootRoute, path: 'privacy', @@ -221,7 +224,6 @@ const namespaceRoute = createRoute({ const skillDetailRoute = createRoute({ getParentRoute: () => rootRoute, path: '/space/$namespace/$slug', - beforeLoad: requireAuth, validateSearch: (search: Record): { returnTo?: string } => ({ returnTo: typeof search.returnTo === 'string' && search.returnTo.startsWith('/') ? search.returnTo : undefined, }), @@ -298,6 +300,13 @@ const dashboardReviewDetailRoute = createRoute({ component: ReviewDetailPage, }) +const dashboardNamespaceReviewDetailRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'dashboard/namespaces/$slug/reviews/$id', + beforeLoad: requireAuth, + component: NamespaceReviewDetailPage, +}) + const dashboardPromotionsRoute = createRoute({ getParentRoute: () => rootRoute, path: 'dashboard/promotions', @@ -397,6 +406,7 @@ const routeTree = rootRoute.addChildren([ skillsRoute, loginRoute, registerRoute, + resetPasswordRoute, privacyRoute, searchRoute, termsRoute, @@ -408,6 +418,7 @@ const routeTree = rootRoute.addChildren([ dashboardNamespacesRoute, dashboardNamespaceMembersRoute, dashboardNamespaceReviewsRoute, + dashboardNamespaceReviewDetailRoute, dashboardGovernanceRoute, dashboardReviewsRoute, dashboardReportsRoute, diff --git a/web/src/features/admin/use-admin-users.ts b/web/src/features/admin/use-admin-users.ts index be9c77432..7c0abf73d 100644 --- a/web/src/features/admin/use-admin-users.ts +++ b/web/src/features/admin/use-admin-users.ts @@ -97,3 +97,13 @@ export function useEnableUser() { }, }) } + +export function useTriggerUserPasswordReset() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: (userId: string) => adminApi.triggerPasswordReset(userId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['admin', 'users'] }) + }, + }) +} diff --git a/web/src/features/publish/publish-error-utils.test.ts b/web/src/features/publish/publish-error-utils.test.ts new file mode 100644 index 000000000..119881e4c --- /dev/null +++ b/web/src/features/publish/publish-error-utils.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest' +import { + extractPrecheckWarnings, + isFrontmatterFailureMessage, + isPrecheckConfirmationMessage, + isPrecheckFailureMessage, + isVersionExistsMessage, +} from './publish-error-utils' + +describe('publish-error-utils', () => { + it('detects confirmation-required warnings in English', () => { + expect(isPrecheckConfirmationMessage('Pre-publish warnings require confirmation before publishing:\n- warning')).toBe(true) + }) + + it('detects confirmation-required warnings in Chinese', () => { + expect(isPrecheckConfirmationMessage('预发布发现以下风险提醒,确认后仍可继续发布:\n- 风险提醒')).toBe(true) + }) + + it('extracts warning lines from a confirmation message', () => { + expect(extractPrecheckWarnings( + 'Pre-publish warnings require confirmation before publishing:\n- Disallowed file extension: malware.exe\n- SKILL.md line 5 contains a value that looks like a secret or token.' + )).toEqual([ + 'Disallowed file extension: malware.exe', + 'SKILL.md line 5 contains a value that looks like a secret or token.', + ]) + }) + + it('keeps existing blocking precheck detection', () => { + expect(isPrecheckFailureMessage('Pre-publish validation failed: validator blocked publish')).toBe(true) + }) + + it('keeps version and frontmatter detection helpers', () => { + expect(isVersionExistsMessage('Version already exists')).toBe(true) + expect(isFrontmatterFailureMessage('Invalid SKILL.md frontmatter')).toBe(true) + }) +}) diff --git a/web/src/features/publish/publish-error-utils.ts b/web/src/features/publish/publish-error-utils.ts new file mode 100644 index 000000000..dd115214a --- /dev/null +++ b/web/src/features/publish/publish-error-utils.ts @@ -0,0 +1,74 @@ +const PRECHECK_CONFIRM_MARKERS = [ + 'Pre-publish warnings require confirmation before publishing', + '预发布发现以下风险提醒,确认后仍可继续发布', +] + +const PRECHECK_FAILURE_MARKERS = [ + 'error.skill.publish.precheck.failed', + 'Pre-publish validation failed', + '预发布校验失败', + 'looks like a secret or token', +] + +const VERSION_EXISTS_MARKERS = [ + 'error.skill.version.exists', + 'Version already exists', + '版本已存在', +] + +const FRONTMATTER_FAILURE_MARKERS = [ + 'Invalid SKILL.md frontmatter', + '技能包校验失败:Invalid SKILL.md frontmatter', +] + +function includesAnyMarker(message: string | undefined, markers: string[]): boolean { + if (!message) { + return false + } + + return markers.some((marker) => message.includes(marker)) +} + +export function isVersionExistsMessage(message?: string): boolean { + return includesAnyMarker(message, VERSION_EXISTS_MARKERS) +} + +export function isPrecheckFailureMessage(message?: string): boolean { + return includesAnyMarker(message, PRECHECK_FAILURE_MARKERS) +} + +export function isPrecheckConfirmationMessage(message?: string): boolean { + return includesAnyMarker(message, PRECHECK_CONFIRM_MARKERS) +} + +export function isFrontmatterFailureMessage(message?: string): boolean { + return includesAnyMarker(message, FRONTMATTER_FAILURE_MARKERS) +} + +export function extractPrecheckWarnings(message?: string): string[] { + if (!message) { + return [] + } + + const normalized = message.replace(/\r/g, '').trim() + if (!normalized) { + return [] + } + + return normalized + .split('\n') + .map((line, index) => { + const trimmed = line.trim() + if (!trimmed) { + return null + } + + if (index === 0 && isPrecheckConfirmationMessage(trimmed)) { + const firstWarning = trimmed.replace(/^.*?[::]\s*/, '').trim() + return firstWarning && !isPrecheckConfirmationMessage(firstWarning) ? firstWarning : null + } + + return trimmed.replace(/^[-*•]\s*/, '') + }) + .filter((line): line is string => Boolean(line)) +} diff --git a/web/src/features/review/review-paths.test.ts b/web/src/features/review/review-paths.test.ts new file mode 100644 index 000000000..bcd612005 --- /dev/null +++ b/web/src/features/review/review-paths.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it } from 'vitest' +import { + buildGlobalReviewsPath, + buildNamespaceReviewDetailPath, + buildNamespaceReviewsPath, + canAccessGlobalReviewCenter, + canAccessReviewCenter, + canManageNamespaceReviews, + getPreferredNamespaceReviewEntry, +} from './review-paths' + +describe('review-paths', () => { + it('builds the global reviews path', () => { + expect(buildGlobalReviewsPath()).toBe('/dashboard/reviews') + }) + + it('builds namespace review paths', () => { + expect(buildNamespaceReviewsPath('team alpha')).toBe('/dashboard/namespaces/team%20alpha/reviews') + expect(buildNamespaceReviewDetailPath('team alpha', 12)).toBe('/dashboard/namespaces/team%20alpha/reviews/12') + }) + + it('detects global review access from platform roles', () => { + expect(canAccessGlobalReviewCenter(['SKILL_ADMIN'])).toBe(true) + expect(canAccessGlobalReviewCenter(['USER_ADMIN'])).toBe(true) + expect(canAccessGlobalReviewCenter(['SUPER_ADMIN'])).toBe(true) + expect(canAccessGlobalReviewCenter(['USER'])).toBe(false) + }) + + it('recognizes namespace review managers', () => { + expect(canManageNamespaceReviews('OWNER')).toBe(true) + expect(canManageNamespaceReviews('ADMIN')).toBe(true) + expect(canManageNamespaceReviews('MEMBER')).toBe(false) + }) + + it('prefers active team namespaces for namespace review entry', () => { + expect(getPreferredNamespaceReviewEntry([ + { + id: 1, + slug: 'archived-team', + displayName: 'Archived Team', + type: 'TEAM', + status: 'ARCHIVED', + immutable: false, + canFreeze: false, + canUnfreeze: false, + canArchive: false, + canRestore: false, + currentUserRole: 'ADMIN', + createdAt: '', + }, + { + id: 2, + slug: 'active-team', + displayName: 'Active Team', + type: 'TEAM', + status: 'ACTIVE', + immutable: false, + canFreeze: false, + canUnfreeze: false, + canArchive: false, + canRestore: false, + currentUserRole: 'OWNER', + createdAt: '', + }, + ])?.slug).toBe('active-team') + }) + + it('returns null when no manageable namespace exists', () => { + expect(getPreferredNamespaceReviewEntry([ + { + id: 1, + slug: 'member-team', + displayName: 'Member Team', + type: 'TEAM', + status: 'ACTIVE', + immutable: false, + canFreeze: false, + canUnfreeze: false, + canArchive: false, + canRestore: false, + currentUserRole: 'MEMBER', + createdAt: '', + }, + ])).toBeNull() + }) + + it('detects review center access from either platform roles or namespace roles', () => { + expect(canAccessReviewCenter(['SKILL_ADMIN'], [])).toBe(true) + expect(canAccessReviewCenter([], [ + { + id: 2, + slug: 'team-admin', + displayName: 'Team Admin', + type: 'TEAM', + status: 'ACTIVE', + immutable: false, + canFreeze: false, + canUnfreeze: false, + canArchive: false, + canRestore: false, + currentUserRole: 'ADMIN', + createdAt: '', + }, + ])).toBe(true) + expect(canAccessReviewCenter([], [])).toBe(false) + }) +}) diff --git a/web/src/features/review/review-paths.ts b/web/src/features/review/review-paths.ts new file mode 100644 index 000000000..081f3c04a --- /dev/null +++ b/web/src/features/review/review-paths.ts @@ -0,0 +1,48 @@ +import type { ManagedNamespace, NamespaceRole } from '@/api/types' + +const GLOBAL_REVIEW_PLATFORM_ROLES = ['SKILL_ADMIN', 'USER_ADMIN', 'SUPER_ADMIN'] as const + +export function buildGlobalReviewsPath() { + return '/dashboard/reviews' +} + +export function buildNamespaceReviewsPath(slug: string) { + return `/dashboard/namespaces/${encodeURIComponent(slug)}/reviews` +} + +export function buildNamespaceReviewDetailPath(slug: string, reviewId: number) { + return `/dashboard/namespaces/${encodeURIComponent(slug)}/reviews/${reviewId}` +} + +export function canAccessGlobalReviewCenter(platformRoles?: readonly string[]) { + return GLOBAL_REVIEW_PLATFORM_ROLES.some((role) => platformRoles?.includes(role)) +} + +export function canManageNamespaceReviews(role?: NamespaceRole) { + return role === 'OWNER' || role === 'ADMIN' +} + +export function getPreferredNamespaceReviewEntry( + namespaces?: readonly ManagedNamespace[], +) { + if (!namespaces?.length) { + return null + } + + const manageableNamespaces = namespaces.filter((namespace) => + namespace.type === 'TEAM' && canManageNamespaceReviews(namespace.currentUserRole), + ) + if (manageableNamespaces.length === 0) { + return null + } + + const activeNamespace = manageableNamespaces.find((namespace) => namespace.status === 'ACTIVE') + return activeNamespace ?? manageableNamespaces[0] +} + +export function canAccessReviewCenter( + platformRoles?: readonly string[], + namespaces?: readonly ManagedNamespace[], +) { + return canAccessGlobalReviewCenter(platformRoles) || getPreferredNamespaceReviewEntry(namespaces) !== null +} diff --git a/web/src/features/skill/version-status-badge.tsx b/web/src/features/skill/version-status-badge.tsx new file mode 100644 index 000000000..0a518b04a --- /dev/null +++ b/web/src/features/skill/version-status-badge.tsx @@ -0,0 +1,92 @@ +import { useTranslation } from 'react-i18next' +import { cn } from '@/shared/lib/utils' + +type VersionStatus = + | 'DRAFT' + | 'SCANNING' + | 'SCAN_FAILED' + | 'UPLOADED' + | 'PENDING_REVIEW' + | 'PUBLISHED' + | 'REJECTED' + | 'YANKED' + +const statusStyles: Record = { + PUBLISHED: + 'border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-400', + UPLOADED: + 'border-blue-500/30 bg-blue-500/10 text-blue-700 dark:text-blue-400', + PENDING_REVIEW: + 'border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-400', + REJECTED: + 'border-red-500/30 bg-red-500/10 text-red-700 dark:text-red-400', + SCANNING: + 'border-purple-500/30 bg-purple-500/10 text-purple-700 dark:text-purple-400', + SCAN_FAILED: + 'border-red-500/30 bg-red-500/10 text-red-700 dark:text-red-400', + YANKED: + 'border-border/60 bg-secondary/40 text-muted-foreground', + DRAFT: + 'border-border/60 bg-secondary/40 text-muted-foreground', +} + +const i18nKeys: Record = { + DRAFT: 'skillDetail.versionStatusDraft', + SCANNING: 'skillDetail.versionStatusScanning', + SCAN_FAILED: 'skillDetail.versionStatusScanFailed', + UPLOADED: 'skillDetail.versionStatusUploaded', + PENDING_REVIEW: 'skillDetail.versionStatusPendingReview', + PUBLISHED: 'skillDetail.versionStatusPublished', + REJECTED: 'skillDetail.versionStatusRejected', + YANKED: 'skillDetail.versionStatusYanked', +} + +/** Color-coded row styles (left-border + subtle background) for version cards. */ +export const versionRowStyles: Record = { + UPLOADED: + 'border-l-[3px] !border-l-blue-500 bg-blue-500/[0.03]', + PENDING_REVIEW: + 'border-l-[3px] !border-l-amber-500 bg-amber-500/[0.03]', + REJECTED: + 'border-l-[3px] !border-l-red-500 bg-red-500/[0.04]', + SCANNING: + 'border-l-[3px] !border-l-purple-500 bg-purple-500/[0.03]', + SCAN_FAILED: + 'border-l-[3px] !border-l-red-500 bg-red-500/[0.04]', + PUBLISHED: '', + YANKED: '', + DRAFT: '', +} + +export function getVersionRowStyle(status?: string): string { + if (!status) return '' + return versionRowStyles[status as VersionStatus] ?? '' +} + +export function VersionStatusBadge({ + status, + className, +}: { + status?: string + className?: string +}) { + const { t } = useTranslation() + if (!status) return null + + const style = statusStyles[status as VersionStatus] ?? statusStyles.DRAFT + const label = i18nKeys[status as VersionStatus] + ? t(i18nKeys[status as VersionStatus]) + : status + + return ( + + {label} + + ) +} diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 1f88708e2..b0cec8d43 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -209,6 +209,7 @@ "hidePassword": "Hide password", "submitting": "Logging in...", "submit": "Login", + "forgotPassword": "Forgot password?", "noAccount": "Don't have an account?", "register": "Sign up now", "oauthHint": "After GitHub authentication, you will be automatically redirected back to this site.", @@ -232,21 +233,47 @@ "email": "Email", "password": "Password", "usernamePlaceholder": "3-64 characters: letters, numbers, or underscores", - "emailPlaceholder": "Optional, for account identification", + "emailPlaceholder": "Enter your email", + "emailRequired": "Email is required", "passwordPlaceholder": "At least 8 characters with 3 character types", - "usernameRequired": "Username is required", - "usernameInvalid": "Username must be 3-64 characters and contain only letters, numbers, or underscores", - "emailInvalid": "Email format is invalid", - "passwordRequired": "Password is required", - "passwordTooShort": "Password must be at least 8 characters", - "passwordTooWeak": "Password must include at least 3 character types", - "usernameExists": "Username already exists", - "emailExists": "Email already exists", "submitting": "Registering...", "submit": "Register & Login", "hasAccount": "Already have an account?", "login": "Back to login", - "oauthHint": "Sign in directly with your existing OAuth account, no local password needed." + "oauthHint": "Sign in directly with your existing OAuth account, no local password needed.", + "usernameRequired": "Username is required", + "usernameInvalid": "Only letters, numbers, or underscores allowed (3-64 characters)", + "usernameExists": "Username already exists", + "passwordRequired": "Password is required", + "passwordTooShort": "Password must be at least 8 characters", + "passwordTooWeak": "Password must contain at least 3 character types (uppercase, lowercase, numbers, special)", + "emailInvalid": "Invalid email format", + "emailExists": "Email already exists" + }, + "resetPassword": { + "title": "Reset Password", + "subtitle": "Enter your email, verification code, and new password.", + "email": "Email", + "emailPlaceholder": "Enter email", + "emailRequired": "Please enter your email", + "emailInvalid": "Please enter a valid email address", + "code": "Verification Code", + "codePlaceholder": "Enter 6-digit verification code", + "sendCode": "Send Verification Code", + "sendingCode": "Sending...", + "codeSentMessage": "If the account is eligible, a verification code has been sent.", + "codeRequired": "Please enter the verification code", + "newPassword": "New Password", + "newPasswordPlaceholder": "Enter new password", + "newPasswordRequired": "Please enter a new password", + "confirmPassword": "Confirm Password", + "confirmPasswordPlaceholder": "Re-enter new password", + "passwordMismatch": "The two passwords do not match", + "submit": "Reset Password", + "submitting": "Resetting...", + "successMessage": "Password reset successful. Please sign in with your new password.", + "genericError": "Failed to reset password", + "backToLogin": "Back to login" }, "device": { "title": "Device Authorization", @@ -529,6 +556,7 @@ "approveUser": "Approve", "disable": "Disable", "enable": "Enable", + "resetPassword": "Reset Password", "totalRecords": "Total {{total}} records, page {{page}}", "prevPage": "Previous", "nextPage": "Next", @@ -543,7 +571,8 @@ "roleSuperAdmin": "Super Admin", "confirmAction": "Confirm Action", "confirmDisable": "Are you sure you want to disable user {{username}}?", - "confirmEnable": "Are you sure you want to enable user {{username}}?" + "confirmEnable": "Are you sure you want to enable user {{username}}?", + "confirmResetPassword": "Send password reset verification code to user {{username}}?" }, "adminLabels": { "title": "Label Management", @@ -650,6 +679,7 @@ "subtitle": "Manage your display name and personal information.", "displayName": "Display Name", "email": "Email", + "resetPassword": "Reset Password", "edit": "Edit", "save": "Save", "saving": "Saving...", @@ -777,6 +807,7 @@ "versionStatusDraft": "Draft", "versionStatusScanning": "Scanning", "versionStatusScanFailed": "Scan Failed", + "versionStatusUploaded": "Uploaded", "versionStatusPendingReview": "Pending Review", "versionStatusPublished": "Published", "versionStatusRejected": "Rejected", @@ -825,6 +856,18 @@ "withdrawReviewSuccessTitle": "Review withdrawn", "withdrawReviewSuccessDescription": "Version {{version}} has been withdrawn from review.", "withdrawReviewErrorTitle": "Failed to withdraw review", + "confirmPublish": "Confirm Publish", + "confirmPublishDialogTitle": "Confirm publish", + "confirmPublishDialogDescription": "Publish version {{version}} as a private skill? It will be available for you to download and install, but not visible on the marketplace.", + "confirmPublishSuccessTitle": "Version published", + "confirmPublishSuccessDescription": "Version {{version}} has been published as a private skill.", + "confirmPublishErrorTitle": "Failed to confirm publish", + "submitReview": "Submit for Review", + "submitReviewDialogTitle": "Submit for review", + "submitReviewDialogDescription": "Submit version {{version}} for public review? Once approved, it will be visible on the marketplace.", + "submitReviewSuccessTitle": "Submitted for review", + "submitReviewSuccessDescription": "Version {{version}} has been submitted for review.", + "submitReviewErrorTitle": "Failed to submit for review", "deleteVersion": "Delete Version", "deleteVersionConfirmTitle": "Delete version", "deleteVersionConfirmDescription": "Version {{version}} cannot be recovered after deletion. Continue?", @@ -868,6 +911,9 @@ "rereleaseSuccessTitle": "Version re-released", "rereleaseSuccessDescription": "Created v{{target}} from v{{source}}.", "rereleaseErrorTitle": "Failed to re-release version", + "rereleaseWarningTitle": "Pre-publish warning", + "rereleaseWarningDescription": "We found the following risk reminders. If you understand them and still want to proceed, you can continue re-releasing.", + "rereleaseWarningConfirm": "Continue re-releasing", "yankVersion": "Yank Current Version", "promoteToGlobal": "Promote to Global", "promotionSectionTitle": "Promote to Global", @@ -1035,6 +1081,7 @@ "sortLabel": "Time Order", "sortNewest": "Newest first", "sortOldest": "Oldest first", + "openReview": "Open review", "pageSummary": "Total {{total}} records, page {{page}}", "prevPage": "Previous", "nextPage": "Next", @@ -1181,6 +1228,10 @@ "versionExistsDescription": "This skill version has already been published. Update the version in SKILL.md, rebuild the package, and upload it again.", "precheckFailedTitle": "Pre-publish check failed", "precheckFailedDescription": "The package appears to contain a secret, token, or password. Replace real credentials with placeholders and try again.", + "warningConfirmTitle": "Pre-publish warning", + "warningConfirmDescription": "We found the following risk reminders. If you understand them and still want to proceed, you can continue publishing.", + "warningConfirmContinue": "Continue publishing", + "warningConfirmCancel": "Go back and fix", "frontmatterFailedTitle": "SKILL.md format is invalid", "frontmatterFailedDescription": "Please check the YAML frontmatter at the top of SKILL.md. If a field value contains a colon, wrap it in quotes.", "selectRequired": "Please select namespace and file" diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index 92920e8fb..0e49a24f6 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -209,6 +209,7 @@ "hidePassword": "隐藏密码", "submitting": "登录中...", "submit": "登录", + "forgotPassword": "忘记密码?", "noAccount": "还没有账号?", "register": "立即注册", "oauthHint": "使用 GitHub 登录时,认证完成后会自动返回当前站点。", @@ -232,21 +233,47 @@ "email": "邮箱", "password": "密码", "usernamePlaceholder": "3-64 位字母、数字或下划线", - "emailPlaceholder": "可选,用于后续账号识别", + "emailPlaceholder": "请输入邮箱", + "emailRequired": "请输入邮箱", "passwordPlaceholder": "至少 8 位,包含 3 种字符类型", - "usernameRequired": "请输入用户名", - "usernameInvalid": "用户名需为 3-64 位,且只能包含字母、数字或下划线", - "emailInvalid": "邮箱格式不正确", - "passwordRequired": "请输入密码", - "passwordTooShort": "密码至少需要 8 位", - "passwordTooWeak": "密码至少需要包含 3 种字符类型", - "usernameExists": "用户名已存在", - "emailExists": "邮箱已存在", "submitting": "注册中...", "submit": "注册并登录", "hasAccount": "已有账号?", "login": "返回登录", - "oauthHint": "直接使用现有 OAuth 账户进入平台,无需再创建本地密码。" + "oauthHint": "直接使用现有 OAuth 账户进入平台,无需再创建本地密码。", + "usernameRequired": "请输入用户名", + "usernameInvalid": "仅支持字母、数字或下划线(3-64 位)", + "usernameExists": "用户名已存在", + "passwordRequired": "请输入密码", + "passwordTooShort": "密码至少需要 8 个字符", + "passwordTooWeak": "密码需包含至少 3 种字符类型(大写、小写、数字、特殊字符)", + "emailInvalid": "邮箱格式不正确", + "emailExists": "邮箱已存在" + }, + "resetPassword": { + "title": "重置密码", + "subtitle": "输入邮箱、验证码和新密码以完成重置。", + "email": "邮箱", + "emailPlaceholder": "请输入邮箱", + "emailRequired": "请输入邮箱", + "emailInvalid": "请输入正确的邮箱格式", + "code": "验证码", + "codePlaceholder": "请输入 6 位验证码", + "sendCode": "发送验证码", + "sendingCode": "发送中...", + "codeSentMessage": "如果账号符合条件,验证码已发送。", + "codeRequired": "请输入验证码", + "newPassword": "新密码", + "newPasswordPlaceholder": "请输入新密码", + "newPasswordRequired": "请输入新密码", + "confirmPassword": "确认新密码", + "confirmPasswordPlaceholder": "请再次输入新密码", + "passwordMismatch": "两次输入的密码不一致", + "submit": "重置密码", + "submitting": "重置中...", + "successMessage": "密码重置成功,请使用新密码登录。", + "genericError": "重置密码失败", + "backToLogin": "返回登录" }, "device": { "title": "设备授权", @@ -529,6 +556,7 @@ "approveUser": "审批通过", "disable": "禁用", "enable": "启用", + "resetPassword": "重置密码", "totalRecords": "共 {{total}} 条记录,第 {{page}} 页", "prevPage": "上一页", "nextPage": "下一页", @@ -543,7 +571,8 @@ "roleSuperAdmin": "超级管理员", "confirmAction": "确认操作", "confirmDisable": "确定要禁用用户 {{username}} 吗?", - "confirmEnable": "确定要启用用户 {{username}} 吗?" + "confirmEnable": "确定要启用用户 {{username}} 吗?", + "confirmResetPassword": "确定给用户 {{username}} 发送密码重置验证码吗?" }, "adminLabels": { "title": "标签管理", @@ -650,6 +679,7 @@ "subtitle": "管理你的昵称和个人信息。", "displayName": "昵称", "email": "邮箱", + "resetPassword": "重置密码", "edit": "编辑", "save": "保存", "saving": "保存中...", @@ -777,6 +807,7 @@ "versionStatusDraft": "草稿", "versionStatusScanning": "安全扫描中", "versionStatusScanFailed": "扫描失败", + "versionStatusUploaded": "已上传", "versionStatusPendingReview": "审核中", "versionStatusPublished": "已发布", "versionStatusRejected": "已拒绝", @@ -825,6 +856,19 @@ "withdrawReviewSuccessTitle": "已撤销审核", "withdrawReviewSuccessDescription": "版本 {{version}} 已撤销审核。", "withdrawReviewErrorTitle": "撤销审核失败", + "confirmPublish": "确认发布", + "confirmPublishDialogTitle": "确认发布", + "confirmPublishDialogDescription": "将版本 {{version}} 发布为私有技能?发布后您可以下载和安装,但不会在市场展示。", + "confirmPublishSuccessTitle": "版本已发布", + "confirmPublishSuccessDescription": "版本 {{version}} 已发布为私有技能。", + "confirmPublishErrorTitle": "确认发布失败", + "submitReview": "提交审核", + "submitReviewDialogTitle": "提交审核", + "submitReviewDialogDescription": "将版本 {{version}} 提交公开审核?审核通过后将在市场展示。", + "submitReviewSuccessTitle": "已提交审核", + "submitReviewSuccessDescription": "版本 {{version}} 已提交审核。", + "submitReviewErrorTitle": "提交审核失败", + "withdrawReviewErrorTitle": "撤销审核失败", "deleteVersion": "删除版本", "deleteVersionConfirmTitle": "确认删除版本", "deleteVersionConfirmDescription": "版本 {{version}} 删除后无法恢复,确定继续吗?", @@ -868,6 +912,9 @@ "rereleaseSuccessTitle": "版本已重新发布", "rereleaseSuccessDescription": "已基于 v{{source}} 创建新版本 v{{target}}。", "rereleaseErrorTitle": "重新发布版本失败", + "rereleaseWarningTitle": "发布前风险提醒", + "rereleaseWarningDescription": "检测到以下风险项。若你确认这些内容可以接受,仍可继续重新发布。", + "rereleaseWarningConfirm": "继续重新发布", "yankVersion": "撤回当前版本", "promoteToGlobal": "申请提升到全局", "promotionSectionTitle": "提升到全局", @@ -1035,6 +1082,7 @@ "sortLabel": "时间排序", "sortNewest": "最新优先", "sortOldest": "最早优先", + "openReview": "进入审核详情", "pageSummary": "共 {{total}} 条记录,第 {{page}} 页", "prevPage": "上一页", "nextPage": "下一页", @@ -1181,6 +1229,10 @@ "versionExistsDescription": "当前技能版本已经发布过,请修改 SKILL.md 中的 version 后重新打包上传。", "precheckFailedTitle": "发布前校验未通过", "precheckFailedDescription": "技能包中包含疑似密钥、令牌或密码内容。请将真实凭证替换为占位符后再重试。", + "warningConfirmTitle": "发布前风险提醒", + "warningConfirmDescription": "检测到以下风险项。若你确认这些内容可以接受,仍可继续发布。", + "warningConfirmContinue": "继续发布", + "warningConfirmCancel": "返回修改", "frontmatterFailedTitle": "SKILL.md 格式有误", "frontmatterFailedDescription": "请检查 SKILL.md 顶部 frontmatter 的 YAML 格式。若字段值中包含冒号,请用引号包裹。", "selectRequired": "请选择命名空间和文件" diff --git a/web/src/pages/admin/users.test.tsx b/web/src/pages/admin/users.test.tsx index a47ade76e..1b27359c6 100644 --- a/web/src/pages/admin/users.test.tsx +++ b/web/src/pages/admin/users.test.tsx @@ -64,6 +64,7 @@ vi.mock('@/features/admin/use-admin-users', () => ({ useApproveUser: () => ({ mutate: vi.fn(), isPending: false }), useDisableUser: () => ({ mutateAsync: vi.fn(), isPending: false }), useEnableUser: () => ({ mutateAsync: vi.fn(), isPending: false }), + useTriggerUserPasswordReset: () => ({ mutateAsync: vi.fn(), isPending: false }), useUpdateUserRole: () => ({ mutateAsync: vi.fn(), isPending: false }), })) diff --git a/web/src/pages/admin/users.tsx b/web/src/pages/admin/users.tsx index 29caf4958..1dc051f29 100644 --- a/web/src/pages/admin/users.tsx +++ b/web/src/pages/admin/users.tsx @@ -29,7 +29,14 @@ import { DialogTitle, } from '@/shared/ui/dialog' import { Label } from '@/shared/ui/label' -import { useAdminUsers, useApproveUser, useDisableUser, useEnableUser, useUpdateUserRole } from '@/features/admin/use-admin-users' +import { + useAdminUsers, + useApproveUser, + useDisableUser, + useEnableUser, + useTriggerUserPasswordReset, + useUpdateUserRole, +} from '@/features/admin/use-admin-users' import type { AdminUser } from '@/features/admin/use-admin-users' /** @@ -54,7 +61,7 @@ export function AdminUsersPage() { const [roleDialogOpen, setRoleDialogOpen] = useState(false) const [newRole, setNewRole] = useState('') const [confirmDialogOpen, setConfirmDialogOpen] = useState(false) - const [actionType, setActionType] = useState<'ban' | 'unban'>('ban') + const [actionType, setActionType] = useState<'ban' | 'unban' | 'reset'>('ban') const { data, isLoading } = useAdminUsers({ search, @@ -67,6 +74,7 @@ export function AdminUsersPage() { const approveUserMutation = useApproveUser() const disableUserMutation = useDisableUser() const enableUserMutation = useEnableUser() + const triggerPasswordResetMutation = useTriggerUserPasswordReset() const formatDate = (dateString: string) => { return formatLocalDateTime(dateString, i18n.language) @@ -105,6 +113,12 @@ export function AdminUsersPage() { setConfirmDialogOpen(true) } + const handleTriggerPasswordReset = (user: AdminUser) => { + setSelectedUser(user) + setActionType('reset') + setConfirmDialogOpen(true) + } + const confirmRoleChange = async () => { if (!selectedUser || !newRole || newRole === (selectedUser.platformRoles[0] || 'USER')) return try { @@ -116,18 +130,20 @@ export function AdminUsersPage() { } } - const confirmStatusChange = async () => { + const confirmUserAction = async () => { if (!selectedUser) return try { if (actionType === 'ban') { await disableUserMutation.mutateAsync(selectedUser.userId) - } else { + } else if (actionType === 'unban') { await enableUserMutation.mutateAsync(selectedUser.userId) + } else { + await triggerPasswordResetMutation.mutateAsync(selectedUser.userId) } setConfirmDialogOpen(false) setSelectedUser(null) } catch (error) { - console.error('Failed to update status:', error) + console.error('Failed to apply user action:', error) } } @@ -259,6 +275,13 @@ export function AdminUsersPage() { {t('adminUsers.enable')} )} +

@@ -342,14 +365,21 @@ export function AdminUsersPage() { {t('adminUsers.confirmAction')} - {actionType === 'ban' ? t('adminUsers.confirmDisable', { username: selectedUser?.username }) : t('adminUsers.confirmEnable', { username: selectedUser?.username })} + {actionType === 'ban' + ? t('adminUsers.confirmDisable', { username: selectedUser?.username }) + : actionType === 'unban' + ? t('adminUsers.confirmEnable', { username: selectedUser?.username }) + : t('adminUsers.confirmResetPassword', { username: selectedUser?.username })} - diff --git a/web/src/pages/dashboard/my-skills.tsx b/web/src/pages/dashboard/my-skills.tsx index 74cd0c65f..5f437cd79 100644 --- a/web/src/pages/dashboard/my-skills.tsx +++ b/web/src/pages/dashboard/my-skills.tsx @@ -83,6 +83,9 @@ export function MySkillsPage() { if (status === 'SCAN_FAILED') { return t('mySkills.statusScanFailed') } + if (status === 'UPLOADED') { + return t('skillDetail.versionStatusUploaded') + } return status } @@ -108,6 +111,9 @@ export function MySkillsPage() { if (status === 'SCAN_FAILED') { return 'status-pill status-pill--rejected' } + if (status === 'UPLOADED') { + return 'status-pill status-pill--review' + } return 'status-pill' } diff --git a/web/src/pages/dashboard/namespace-reviews.test.ts b/web/src/pages/dashboard/namespace-reviews.test.ts index 8e0310888..735307ca2 100644 --- a/web/src/pages/dashboard/namespace-reviews.test.ts +++ b/web/src/pages/dashboard/namespace-reviews.test.ts @@ -3,6 +3,7 @@ import { renderToStaticMarkup } from 'react-dom/server' import { createElement } from 'react' vi.mock('@tanstack/react-router', () => ({ + Link: ({ children, to }: { children: unknown; to: string }) => createElement('a', { href: to }, children as string), useParams: () => ({ slug: 'test-ns' }), })) @@ -127,6 +128,8 @@ describe('NamespaceReviewsPage', () => { const html = renderToStaticMarkup(createElement(NamespaceReviewsPage)) expect(html).toContain('nsReviews.pageSummary') + expect(html).toContain('/dashboard/namespaces/test-ns/reviews/1') + expect(html).toContain('nsReviews.openReview') expect(paginationProps).toHaveLength(1) expect(paginationProps[0]?.page).toBe(0) expect(paginationProps[0]?.totalPages).toBe(2) @@ -154,4 +157,18 @@ describe('NamespaceReviewsPage', () => { expect(paginationProps).toHaveLength(0) }) + + it('does not enable review queries before namespace detail resolves', () => { + useNamespaceDetailMock.mockReturnValue({ + data: undefined, + isLoading: true, + }) + + renderToStaticMarkup(createElement(NamespaceReviewsPage)) + + expect(useReviewListMock).toHaveBeenCalled() + for (const call of useReviewListMock.mock.calls) { + expect(call[5]).toBe(false) + } + }) }) diff --git a/web/src/pages/dashboard/namespace-reviews.tsx b/web/src/pages/dashboard/namespace-reviews.tsx index 009d5f4f0..fb995e8ac 100644 --- a/web/src/pages/dashboard/namespace-reviews.tsx +++ b/web/src/pages/dashboard/namespace-reviews.tsx @@ -1,6 +1,7 @@ import { useState } from 'react' -import { useParams } from '@tanstack/react-router' +import { Link, useParams } from '@tanstack/react-router' import { useTranslation } from 'react-i18next' +import { buildNamespaceReviewDetailPath } from '@/features/review/review-paths' import { formatLocalDateTime } from '@/shared/lib/date-time' import { Card } from '@/shared/ui/card' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select' @@ -15,8 +16,9 @@ type ReviewStatus = 'PENDING' | 'APPROVED' | 'REJECTED' type TimeSortDirection = 'ASC' | 'DESC' const PAGE_SIZE = 10 -function ReviewListSection({ namespaceId }: { namespaceId?: number }) { +function ReviewListSection({ namespaceId, slug }: { namespaceId?: number; slug: string }) { const { t, i18n } = useTranslation() + const reviewsEnabled = typeof namespaceId === 'number' && namespaceId > 0 const [pages, setPages] = useState>({ PENDING: 0, APPROVED: 0, @@ -24,9 +26,9 @@ function ReviewListSection({ namespaceId }: { namespaceId?: number }) { }) const [activeStatus, setActiveStatus] = useState('PENDING') const [sortDirection, setSortDirection] = useState('DESC') - const pending = useReviewList('PENDING', namespaceId, pages.PENDING, PAGE_SIZE, sortDirection, activeStatus === 'PENDING') - const approved = useReviewList('APPROVED', namespaceId, pages.APPROVED, PAGE_SIZE, sortDirection, activeStatus === 'APPROVED') - const rejected = useReviewList('REJECTED', namespaceId, pages.REJECTED, PAGE_SIZE, sortDirection, activeStatus === 'REJECTED') + const pending = useReviewList('PENDING', namespaceId, pages.PENDING, PAGE_SIZE, sortDirection, reviewsEnabled && activeStatus === 'PENDING') + const approved = useReviewList('APPROVED', namespaceId, pages.APPROVED, PAGE_SIZE, sortDirection, reviewsEnabled && activeStatus === 'APPROVED') + const rejected = useReviewList('REJECTED', namespaceId, pages.REJECTED, PAGE_SIZE, sortDirection, reviewsEnabled && activeStatus === 'REJECTED') const changePage = (status: ReviewStatus, nextPage: number) => { setPages((current) => ({ ...current, [status]: nextPage })) @@ -90,6 +92,14 @@ function ReviewListSection({ namespaceId }: { namespaceId?: number }) { {review.reviewComment ? (

{review.reviewComment}

) : null} +
+ + {t('nsReviews.openReview')} + +
))} {query.data ? renderPagination(status, query.data.totalElements, query.data.totalPages) : null} @@ -151,7 +161,7 @@ export function NamespaceReviewsPage() { {readOnlyMessage} ) : null} - + ) } diff --git a/web/src/pages/dashboard/publish.tsx b/web/src/pages/dashboard/publish.tsx index 1de466955..3b2ec57e3 100644 --- a/web/src/pages/dashboard/publish.tsx +++ b/web/src/pages/dashboard/publish.tsx @@ -2,6 +2,13 @@ import { useState } from 'react' import { useNavigate } from '@tanstack/react-router' import { useTranslation } from 'react-i18next' import { UploadZone } from '@/features/publish/upload-zone' +import { + extractPrecheckWarnings, + isFrontmatterFailureMessage, + isPrecheckConfirmationMessage, + isPrecheckFailureMessage, + isVersionExistsMessage, +} from '@/features/publish/publish-error-utils' import { Button } from '@/shared/ui/button' import { Select, @@ -15,46 +22,11 @@ import { Label } from '@/shared/ui/label' import { Card } from '@/shared/ui/card' import { usePublishSkill } from '@/shared/hooks/use-skill-queries' import { useMyNamespaces } from '@/shared/hooks/use-namespace-queries' +import { ConfirmDialog } from '@/shared/components/confirm-dialog' import { DashboardPageHeader } from '@/shared/components/dashboard-page-header' import { toast } from '@/shared/lib/toast' import { ApiError } from '@/api/client' -/** - * Skill publish page used inside the dashboard. - * - * It coordinates namespace selection, visibility selection, zip upload, and backend publish error - * translation into user-facing toasts. - */ -function isVersionExistsMessage(message?: string): boolean { - if (!message) { - return false - } - - return message.includes('error.skill.version.exists') - || message.includes('Version already exists') - || message.includes('版本已存在') -} - -function isPrecheckFailureMessage(message?: string): boolean { - if (!message) { - return false - } - - return message.includes('error.skill.publish.precheck.failed') - || message.includes('Pre-publish validation failed') - || message.includes('预发布校验失败') - || message.includes('looks like a secret or token') -} - -function isFrontmatterFailureMessage(message?: string): boolean { - if (!message) { - return false - } - - return message.includes('Invalid SKILL.md frontmatter') - || message.includes('技能包校验失败:Invalid SKILL.md frontmatter') -} - const EMPTY_NAMESPACE_VALUE = '__select_namespace__' export function PublishPage() { @@ -63,6 +35,8 @@ export function PublishPage() { const [selectedFile, setSelectedFile] = useState(null) const [namespaceSlug, setNamespaceSlug] = useState('') const [visibility, setVisibility] = useState('PUBLIC') + const [warningDialogOpen, setWarningDialogOpen] = useState(false) + const [precheckWarnings, setPrecheckWarnings] = useState([]) const { data: namespaces, isLoading: isLoadingNamespaces } = useMyNamespaces() const publishMutation = usePublishSkill() @@ -73,9 +47,17 @@ export function PublishPage() { const handleRemoveSelectedFile = () => { setSelectedFile(null) + setPrecheckWarnings([]) + setWarningDialogOpen(false) } - const handlePublish = async () => { + const handleFileSelect = (file: File | null) => { + setSelectedFile(file) + setPrecheckWarnings([]) + setWarningDialogOpen(false) + } + + const publishSkill = async (confirmWarnings = false) => { if (!selectedFile || !namespaceSlug) { toast.error(t('publish.selectRequired')) return @@ -86,7 +68,10 @@ export function PublishPage() { namespace: namespaceSlug, file: selectedFile, visibility, + confirmWarnings, }) + setPrecheckWarnings([]) + setWarningDialogOpen(false) const skillLabel = `${result.namespace}/${result.slug}@${result.version}` if (result.status === 'PUBLISHED') { toast.success( @@ -114,6 +99,12 @@ export function PublishPage() { return } + if (error instanceof ApiError && isPrecheckConfirmationMessage(error.serverMessage || error.message)) { + setPrecheckWarnings(extractPrecheckWarnings(error.serverMessage || error.message)) + setWarningDialogOpen(true) + return + } + if (error instanceof ApiError && isPrecheckFailureMessage(error.serverMessage || error.message)) { toast.error( t('publish.precheckFailedTitle'), @@ -134,6 +125,10 @@ export function PublishPage() { } } + const handlePublish = async () => { + await publishSkill(false) + } + return (
@@ -195,7 +190,7 @@ export function PublishPage() { {selectedFile && ( @@ -230,6 +225,27 @@ export function PublishPage() { {publishMutation.isPending ? t('publish.publishing') : t('publish.confirm')} + + +

{t('publish.warningConfirmDescription')}

+ {precheckWarnings.length > 0 && ( +
    + {precheckWarnings.map((warning) => ( +
  • {warning}
  • + ))} +
+ )} +
+ )} + confirmText={t('publish.warningConfirmContinue')} + cancelText={t('publish.warningConfirmCancel')} + onConfirm={() => publishSkill(true)} + /> ) } diff --git a/web/src/pages/dashboard/review-detail.test.tsx b/web/src/pages/dashboard/review-detail.test.tsx index 84ba117b5..6b8cde319 100644 --- a/web/src/pages/dashboard/review-detail.test.tsx +++ b/web/src/pages/dashboard/review-detail.test.tsx @@ -5,7 +5,11 @@ const navigateMock = vi.fn() vi.mock('@tanstack/react-router', () => ({ useNavigate: () => navigateMock, - useParams: () => ({ id: '13' }), + useParams: (options?: { from?: string }) => ( + options?.from === '/dashboard/namespaces/$slug/reviews/$id' + ? { id: '13', slug: 'team-alpha' } + : { id: '13' } + ), })) vi.mock('react-i18next', async () => { @@ -112,6 +116,11 @@ vi.mock('@/features/review/use-review-detail', () => ({ }), })) +const userMock = { platformRoles: ['SKILL_ADMIN'] as string[] } +vi.mock('@/features/auth/use-auth', () => ({ + useAuth: () => ({ user: userMock }), +})) + // Mock hooks used directly by the review-detail page for file browser sidebar vi.mock('@/features/review/use-review-file', () => ({ useReviewFile: () => ({ data: null, isLoading: false, error: null }), @@ -122,11 +131,12 @@ vi.mock('@/api/client', () => ({ WEB_API_PREFIX: '/api/web', })) -import { ReviewDetailPage } from './review-detail' +import { NamespaceReviewDetailPage, ReviewDetailPage } from './review-detail' describe('ReviewDetailPage', () => { beforeEach(() => { navigateMock.mockReset() + userMock.platformRoles = ['SKILL_ADMIN'] useReviewDetailMock.mockReset() useReviewSkillDetailMock.mockReset() useReviewDetailMock.mockReturnValue({ @@ -206,6 +216,81 @@ describe('ReviewDetailPage', () => { expect(html).toContain('review.notFound') }) + it('renders namespace review detail through the namespace route wrapper', () => { + useReviewDetailMock.mockReturnValue({ + data: { + id: 13, + namespace: 'team-alpha', + skillSlug: 'demo-skill', + version: '1.2.0', + status: 'PENDING', + submittedBy: 'local-admin', + submittedByName: 'Local Admin', + submittedAt: '2026-03-19T00:00:00Z', + reviewedBy: null, + reviewedByName: null, + reviewedAt: null, + reviewComment: null, + }, + isLoading: false, + }) + + const html = renderToStaticMarkup() + + expect(html).toContain('review.detail') + expect(html).toContain('demo-skill') + }) + + it('redirects namespace reviews opened through the global route for namespace operators', () => { + userMock.platformRoles = [] + useReviewDetailMock.mockReturnValue({ + data: { + id: 13, + namespace: 'team-alpha', + skillSlug: 'demo-skill', + version: '1.2.0', + status: 'PENDING', + submittedBy: 'local-admin', + submittedByName: 'Local Admin', + submittedAt: '2026-03-19T00:00:00Z', + reviewedBy: null, + reviewedByName: null, + reviewedAt: null, + reviewComment: null, + }, + isLoading: false, + }) + + const html = renderToStaticMarkup() + + expect(html).toBe('') + }) + + it('shows not-found state when the namespace route slug does not match the review namespace', () => { + useReviewDetailMock.mockReturnValue({ + data: { + id: 13, + namespace: 'other-team', + skillSlug: 'demo-skill', + version: '1.2.0', + status: 'PENDING', + submittedBy: 'local-admin', + submittedByName: 'Local Admin', + submittedAt: '2026-03-19T00:00:00Z', + reviewedBy: null, + reviewedByName: null, + reviewedAt: null, + reviewComment: null, + }, + isLoading: false, + }) + + const html = renderToStaticMarkup() + + expect(html).toContain('review.notFound') + expect(html).toContain('review.backToList') + }) + it('disables approval and shows a scanning hint while the active review version is scanning', () => { useReviewSkillDetailMock.mockReturnValue({ data: { diff --git a/web/src/pages/dashboard/review-detail.tsx b/web/src/pages/dashboard/review-detail.tsx index 72a2a8dfe..959d5af37 100644 --- a/web/src/pages/dashboard/review-detail.tsx +++ b/web/src/pages/dashboard/review-detail.tsx @@ -1,7 +1,14 @@ -import { useState } from 'react' +import { useEffect, useState } from 'react' import { useNavigate, useParams } from '@tanstack/react-router' import { useTranslation } from 'react-i18next' import { ChevronDown, Folder } from 'lucide-react' +import { useAuth } from '@/features/auth/use-auth' +import { + buildGlobalReviewsPath, + buildNamespaceReviewDetailPath, + buildNamespaceReviewsPath, + canAccessGlobalReviewCenter, +} from '@/features/review/review-paths' import { formatLocalDateTime } from '@/shared/lib/date-time' import { Button } from '@/shared/ui/button' import { Card } from '@/shared/ui/card' @@ -25,11 +32,18 @@ import { useReviewDetail, useReviewSkillDetail, useApproveReview, useRejectRevie * interaction state because both actions depend on route-local confirmation * dialogs, comment input, and redirect behavior after completion. */ -export function ReviewDetailPage() { - const { id } = useParams({ from: '/dashboard/reviews/$id' }) +function ReviewDetailScreen({ + taskId, + backTo, + namespaceSlug, +}: { + taskId: number + backTo: string + namespaceSlug?: string +}) { const navigate = useNavigate() const { t, i18n } = useTranslation() - const taskId = Number(id) + const { user } = useAuth() const { data: review, isLoading } = useReviewDetail(taskId) const { @@ -40,7 +54,7 @@ export function ReviewDetailPage() { const approveMutation = useApproveReview({ onSuccess: () => { toast.success(t('review.approveSuccess')) - navigate({ to: '/dashboard/reviews' }) + navigate({ to: backTo }) }, onError: (error) => { toast.error(t('review.approveFailed'), resolveReviewActionErrorDescription(error)) @@ -49,7 +63,7 @@ export function ReviewDetailPage() { const rejectMutation = useRejectReview({ onSuccess: () => { toast.success(t('review.rejectSuccess')) - navigate({ to: '/dashboard/reviews' }) + navigate({ to: backTo }) }, onError: (error) => { toast.error(t('review.rejectFailed'), resolveReviewActionErrorDescription(error)) @@ -64,6 +78,12 @@ export function ReviewDetailPage() { const [fileBrowserOpen, setFileBrowserOpen] = useState(true) const [previewNode, setPreviewNode] = useState(null) const [previewDialogOpen, setPreviewDialogOpen] = useState(false) + const hasGlobalReviewAccess = canAccessGlobalReviewCenter(user?.platformRoles) + const shouldRedirectToNamespaceRoute = + !namespaceSlug && + !!review && + review.namespace !== 'global' && + !hasGlobalReviewAccess // File content for preview — uses the review-bound version via review file API const { data: previewContent, isLoading: isLoadingPreview, error: previewError } = useReviewFile( @@ -92,6 +112,17 @@ export function ReviewDetailPage() { return formatLocalDateTime(dateString, i18n.language) } + useEffect(() => { + if (!shouldRedirectToNamespaceRoute || !review) { + return + } + + void navigate({ + to: buildNamespaceReviewDetailPath(review.namespace, review.id), + replace: true, + }) + }, [navigate, review, shouldRedirectToNamespaceRoute]) + const handleApprove = async () => { approveMutation.mutate({ taskId, comment: comment || undefined }) } @@ -113,6 +144,10 @@ export function ReviewDetailPage() { ) } + if (shouldRedirectToNamespaceRoute) { + return null + } + if (!review) { return (
@@ -121,6 +156,23 @@ export function ReviewDetailPage() { ) } + const hasNamespaceMismatch = Boolean(namespaceSlug && review.namespace !== namespaceSlug) + + if (hasNamespaceMismatch) { + return ( +
+
+

{t('review.notFound')}

+
+
+ +
+
+ ) + } + const reviewFiles = reviewSkillDetail?.files const activeReviewVersion = reviewSkillDetail?.versions?.find( (version) => version.version === reviewSkillDetail.activeVersion @@ -136,7 +188,7 @@ export function ReviewDetailPage() {

{t('review.detail')}

{t('review.id')}: {review.id}

- @@ -363,3 +415,26 @@ export function ReviewDetailPage() { ) } + +export function ReviewDetailPage() { + const { id } = useParams({ from: '/dashboard/reviews/$id' }) + + return ( + + ) +} + +export function NamespaceReviewDetailPage() { + const { id, slug } = useParams({ from: '/dashboard/namespaces/$slug/reviews/$id' }) + + return ( + + ) +} diff --git a/web/src/pages/dashboard/reviews.test.ts b/web/src/pages/dashboard/reviews.test.ts index 992c102bd..694a0dce9 100644 --- a/web/src/pages/dashboard/reviews.test.ts +++ b/web/src/pages/dashboard/reviews.test.ts @@ -67,8 +67,14 @@ vi.mock('@/features/review/use-review-list', () => ({ })) const hasRoleMock = vi.fn() +const userMock = { platformRoles: ['SKILL_ADMIN'] } vi.mock('@/features/auth/use-auth', () => ({ - useAuth: () => ({ hasRole: hasRoleMock }), + useAuth: () => ({ hasRole: hasRoleMock, user: userMock }), +})) + +const useMyNamespacesMock = vi.fn() +vi.mock('@/shared/hooks/use-namespace-queries', () => ({ + useMyNamespaces: () => useMyNamespacesMock(), })) vi.mock('@/shared/components/dashboard-page-header', () => ({ @@ -106,7 +112,13 @@ describe('ReviewsPage', () => { paginationProps.length = 0 hasRoleMock.mockReset() useReviewListMock.mockReset() + useMyNamespacesMock.mockReset() hasRoleMock.mockImplementation((role: string) => role === 'SKILL_ADMIN') + userMock.platformRoles = ['SKILL_ADMIN'] + useMyNamespacesMock.mockReturnValue({ + data: [], + isLoading: false, + }) useReviewListMock.mockImplementation((status: string, _namespaceId: unknown, page: number, _size: number, _sortDirection: string, enabled: boolean) => { if (!enabled) { return { data: null, isLoading: false } diff --git a/web/src/pages/dashboard/reviews.tsx b/web/src/pages/dashboard/reviews.tsx index 426da9b8b..4ead6c84b 100644 --- a/web/src/pages/dashboard/reviews.tsx +++ b/web/src/pages/dashboard/reviews.tsx @@ -1,10 +1,16 @@ -import { useState } from 'react' +import { useEffect, useState } from 'react' import { useNavigate } from '@tanstack/react-router' import { FileCheck2 } from 'lucide-react' import { useTranslation } from 'react-i18next' +import { useMyNamespaces } from '@/shared/hooks/use-namespace-queries' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/shared/ui/card' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/shared/ui/tabs' +import { + buildNamespaceReviewsPath, + canAccessGlobalReviewCenter, + getPreferredNamespaceReviewEntry, +} from '@/features/review/review-paths' import { Table, TableBody, @@ -32,7 +38,8 @@ const PAGE_SIZE = 20 export function ReviewsPage() { const { t, i18n } = useTranslation() const navigate = useNavigate() - const { hasRole } = useAuth() + const { hasRole, user } = useAuth() + const { data: myNamespaces, isLoading: isLoadingNamespaces } = useMyNamespaces() const [pages, setPages] = useState>({ PENDING: 0, APPROVED: 0, @@ -43,14 +50,29 @@ export function ReviewsPage() { const isSkillAdmin = hasRole('SKILL_ADMIN') || hasRole('SUPER_ADMIN') const isUserAdmin = hasRole('USER_ADMIN') || hasRole('SUPER_ADMIN') + const hasGlobalReviewAccess = canAccessGlobalReviewCenter(user?.platformRoles) + const namespaceReviewEntry = getPreferredNamespaceReviewEntry(myNamespaces) const showTypeTabs = isSkillAdmin && isUserAdmin // Determine default top-level tab const defaultType = isSkillAdmin ? 'skill' : 'profile' - const pendingQuery = useReviewList('PENDING', undefined, pages.PENDING, PAGE_SIZE, sortDirection, activeStatus === 'PENDING') - const approvedQuery = useReviewList('APPROVED', undefined, pages.APPROVED, PAGE_SIZE, sortDirection, activeStatus === 'APPROVED') - const rejectedQuery = useReviewList('REJECTED', undefined, pages.REJECTED, PAGE_SIZE, sortDirection, activeStatus === 'REJECTED') + useEffect(() => { + if (hasGlobalReviewAccess || isLoadingNamespaces) { + return + } + + if (namespaceReviewEntry) { + void navigate({ to: buildNamespaceReviewsPath(namespaceReviewEntry.slug), replace: true }) + return + } + + void navigate({ to: '/dashboard', replace: true }) + }, [hasGlobalReviewAccess, isLoadingNamespaces, namespaceReviewEntry, navigate]) + + const pendingQuery = useReviewList('PENDING', undefined, pages.PENDING, PAGE_SIZE, sortDirection, hasGlobalReviewAccess && activeStatus === 'PENDING') + const approvedQuery = useReviewList('APPROVED', undefined, pages.APPROVED, PAGE_SIZE, sortDirection, hasGlobalReviewAccess && activeStatus === 'APPROVED') + const rejectedQuery = useReviewList('REJECTED', undefined, pages.REJECTED, PAGE_SIZE, sortDirection, hasGlobalReviewAccess && activeStatus === 'REJECTED') const formatDate = (dateString: string) => formatLocalDateTime(dateString, i18n.language) @@ -211,6 +233,17 @@ export function ReviewsPage() { ) } + if (!hasGlobalReviewAccess) { + return ( +
+ + + Loading... + +
+ ) + } + return (
diff --git a/web/src/pages/login.tsx b/web/src/pages/login.tsx index a23d0e3ef..1aab99c48 100644 --- a/web/src/pages/login.tsx +++ b/web/src/pages/login.tsx @@ -160,6 +160,11 @@ export function LoginPage() { +

+ + {t('login.forgotPassword')} + +

{t('login.noAccount')} {' '} diff --git a/web/src/pages/register.tsx b/web/src/pages/register.tsx index de6b8cf62..a3a0aa6aa 100644 --- a/web/src/pages/register.tsx +++ b/web/src/pages/register.tsx @@ -77,7 +77,7 @@ export function RegisterPage() { function validateEmail(value: string) { const trimmed = value.trim().toLowerCase() if (!trimmed) { - return undefined + return t('register.emailRequired') } if (!EMAIL_PATTERN.test(trimmed)) { return t('register.emailInvalid') @@ -112,6 +112,8 @@ export function RegisterPage() { return { fieldErrors: { username: t('register.usernameRequired') } } case 'validation.auth.local.password.notBlank': return { fieldErrors: { password: t('register.passwordRequired') } } + case 'validation.auth.local.email.notBlank': + return { fieldErrors: { email: t('register.emailRequired') } } case 'validation.auth.local.email.invalid': return { fieldErrors: { email: t('register.emailInvalid') } } case 'error.auth.local.username.invalid': @@ -218,6 +220,7 @@ export function RegisterPage() { } }} placeholder={t('register.emailPlaceholder')} + required aria-invalid={fieldErrors.email ? 'true' : 'false'} onBlur={() => { setFieldErrors((current) => ({ ...current, email: validateEmail(email) })) diff --git a/web/src/pages/reset-password.test.tsx b/web/src/pages/reset-password.test.tsx new file mode 100644 index 000000000..1a919aa31 --- /dev/null +++ b/web/src/pages/reset-password.test.tsx @@ -0,0 +1,54 @@ +import { describe, expect, it, vi } from 'vitest' + +vi.mock('@tanstack/react-router', () => ({ + Link: ({ children }: { children: unknown }) => children, +})) + +vi.mock('react-i18next', async () => { + const actual = await vi.importActual('react-i18next') + return { + ...actual, + useTranslation: () => ({ + t: (key: string) => key, + }), + } +}) + +vi.mock('@/api/client', () => ({ + authApi: { + requestPasswordReset: vi.fn(), + confirmPasswordReset: vi.fn(), + }, +})) + +vi.mock('@/shared/ui/button', () => ({ + Button: ({ children }: { children: unknown }) => children, +})) + +vi.mock('@/shared/ui/card', () => ({ + Card: ({ children }: { children: unknown }) => children, + CardContent: ({ children }: { children: unknown }) => children, + CardDescription: ({ children }: { children: unknown }) => children, + CardHeader: ({ children }: { children: unknown }) => children, + CardTitle: ({ children }: { children: unknown }) => children, +})) + +vi.mock('@/shared/ui/input', () => ({ + Input: () => null, +})) + +import { renderToStaticMarkup } from 'react-dom/server' +import { ResetPasswordPage } from './reset-password' + +describe('ResetPasswordPage', () => { + it('exports a named component function', () => { + expect(typeof ResetPasswordPage).toBe('function') + }) + + it('renders reset-password title and submit action', () => { + const html = renderToStaticMarkup() + expect(html).toContain('resetPassword.title') + expect(html).toContain('resetPassword.sendCode') + expect(html).toContain('resetPassword.submit') + }) +}) diff --git a/web/src/pages/reset-password.tsx b/web/src/pages/reset-password.tsx new file mode 100644 index 000000000..ef4777de9 --- /dev/null +++ b/web/src/pages/reset-password.tsx @@ -0,0 +1,187 @@ +import { Link } from '@tanstack/react-router' +import { FormEvent, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { authApi } from '@/api/client' +import { Button } from '@/shared/ui/button' +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/shared/ui/card' +import { Input } from '@/shared/ui/input' + +/** + * Public page for verifying a reset code and setting a new password. + */ +export function ResetPasswordPage() { + const { t } = useTranslation() + const [email, setEmail] = useState('') + const [code, setCode] = useState('') + const [newPassword, setNewPassword] = useState('') + const [confirmPassword, setConfirmPassword] = useState('') + const [isSubmitting, setIsSubmitting] = useState(false) + const [isSendingCode, setIsSendingCode] = useState(false) + const [isSuccess, setIsSuccess] = useState(false) + const [codeSentMessage, setCodeSentMessage] = useState(null) + const [errorMessage, setErrorMessage] = useState(null) + const emailPattern = /^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$/ + + async function handleSendCode() { + const normalizedEmail = email.trim().toLowerCase() + if (!normalizedEmail) { + setErrorMessage(t('resetPassword.emailRequired')) + return + } + if (!emailPattern.test(normalizedEmail)) { + setErrorMessage(t('resetPassword.emailInvalid')) + return + } + + setIsSendingCode(true) + setErrorMessage(null) + setCodeSentMessage(null) + try { + await authApi.requestPasswordReset({ email: normalizedEmail }) + setCodeSentMessage(t('resetPassword.codeSentMessage')) + } catch (error) { + setErrorMessage(error instanceof Error ? error.message : t('resetPassword.genericError')) + } finally { + setIsSendingCode(false) + } + } + + async function handleSubmit(event: FormEvent) { + event.preventDefault() + + const normalizedEmail = email.trim().toLowerCase() + if (!normalizedEmail) { + setErrorMessage(t('resetPassword.emailRequired')) + return + } + if (!emailPattern.test(normalizedEmail)) { + setErrorMessage(t('resetPassword.emailInvalid')) + return + } + if (!code.trim()) { + setErrorMessage(t('resetPassword.codeRequired')) + return + } + if (!newPassword) { + setErrorMessage(t('resetPassword.newPasswordRequired')) + return + } + if (newPassword !== confirmPassword) { + setErrorMessage(t('resetPassword.passwordMismatch')) + return + } + + setIsSubmitting(true) + setErrorMessage(null) + try { + await authApi.confirmPasswordReset({ + email: normalizedEmail, + code: code.trim(), + newPassword, + }) + setIsSuccess(true) + } catch (error) { + setErrorMessage(error instanceof Error ? error.message : t('resetPassword.genericError')) + } finally { + setIsSubmitting(false) + } + } + + return ( +

+ + + {t('resetPassword.title')} + {t('resetPassword.subtitle')} + + + {isSuccess ? ( +
+

+ {t('resetPassword.successMessage')} +

+ + {t('resetPassword.backToLogin')} + +
+ ) : ( +
+
+ +
+ setEmail(event.target.value)} + placeholder={t('resetPassword.emailPlaceholder')} + autoComplete="email" + required + /> + +
+
+
+ + setCode(event.target.value)} + placeholder={t('resetPassword.codePlaceholder')} + autoComplete="one-time-code" + /> +
+
+ + setNewPassword(event.target.value)} + placeholder={t('resetPassword.newPasswordPlaceholder')} + autoComplete="new-password" + /> +
+
+ + setConfirmPassword(event.target.value)} + placeholder={t('resetPassword.confirmPasswordPlaceholder')} + autoComplete="new-password" + /> +
+ {errorMessage ? ( +

{errorMessage}

+ ) : null} + {codeSentMessage ? ( +

{codeSentMessage}

+ ) : null} + +
+ )} +
+
+
+ ) +} diff --git a/web/src/pages/search.test.tsx b/web/src/pages/search.test.tsx index 726b5553d..a921d5d34 100644 --- a/web/src/pages/search.test.tsx +++ b/web/src/pages/search.test.tsx @@ -46,7 +46,13 @@ vi.mock('@/shared/components/skeleton-loader', () => ({ })) vi.mock('@/shared/components/empty-state', () => ({ - EmptyState: () =>
empty-state
, + EmptyState: ({ title, description }: { title: string; description?: string }) => ( +
+ empty-state + {title} + {description ? {description} : null} +
+ ), })) vi.mock('@/shared/components/pagination', () => ({ @@ -202,4 +208,55 @@ describe('SearchPage', () => { }, }) }) + + it('renders the default skill list when the empty query still returns items', () => { + useSearchMock.mockReturnValue({ + q: '', + label: '', + sort: 'newest', + page: 0, + starredOnly: false, + }) + useSearchSkillsMock.mockReturnValue({ + data: { + items: [{ id: 1, displayName: 'Demo Skill', summary: 'summary', namespace: 'global', slug: 'demo', downloadCount: 1, starCount: 1, ratingCount: 0, updatedAt: '2026-03-20T00:00:00Z', canSubmitPromotion: false }], + total: 1, + page: 0, + size: 12, + }, + isLoading: false, + isFetching: false, + }) + + const html = renderToStaticMarkup() + + expect(html).toContain('skill-card') + expect(html).not.toContain('empty-state') + }) + + it('shows a generic empty state when the default discovery list is empty', () => { + useSearchMock.mockReturnValue({ + q: '', + label: '', + sort: 'newest', + page: 0, + starredOnly: false, + }) + useSearchSkillsMock.mockReturnValue({ + data: { + items: [], + total: 0, + page: 0, + size: 12, + }, + isLoading: false, + isFetching: false, + }) + + const html = renderToStaticMarkup() + + expect(html).toContain('empty-state') + expect(html).toContain('search.noResults') + expect(html).not.toContain('search.enterKeyword') + }) }) diff --git a/web/src/pages/search.tsx b/web/src/pages/search.tsx index 6258ca194..58c421dba 100644 --- a/web/src/pages/search.tsx +++ b/web/src/pages/search.tsx @@ -125,8 +125,6 @@ export function SearchPage() { isLoading: isLoadingStarred, isFetching: isFetchingStarred, } = useMyStars(starredOnly && isAuthenticated) - const shouldShowGuidance = !starredOnly && !q && !selectedLabel - useEffect(() => { // Debounce URL updates while the user is typing so query state stays shareable without // triggering a navigation on every keystroke. @@ -202,10 +200,10 @@ export function SearchPage() { : data ? Math.ceil(data.total / data.size) : 0 - const displayItems = shouldShowGuidance ? [] : (starredOnly ? starredPageItems : (data?.items ?? [])) - const isPageLoading = shouldShowGuidance ? false : (starredOnly ? isLoadingStarred : isLoading) - const isUpdatingResults = shouldShowGuidance ? false : (starredOnly ? isFetchingStarred && !isLoadingStarred : isFetching && !isLoading) - const resultCount = shouldShowGuidance ? 0 : (starredOnly ? filteredStarredSkills.length : (data?.total ?? 0)) + const displayItems = starredOnly ? starredPageItems : (data?.items ?? []) + const isPageLoading = starredOnly ? isLoadingStarred : isLoading + const isUpdatingResults = starredOnly ? isFetchingStarred && !isLoadingStarred : isFetching && !isLoading + const resultCount = starredOnly ? filteredStarredSkills.length : (data?.total ?? 0) return (
@@ -313,11 +311,9 @@ export function SearchPage() { )} diff --git a/web/src/pages/settings/profile.test.ts b/web/src/pages/settings/profile.test.ts index ceb412d4a..9e6e56a8c 100644 --- a/web/src/pages/settings/profile.test.ts +++ b/web/src/pages/settings/profile.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it, vi } from 'vitest' +import React from 'react' vi.mock('react-i18next', async () => { const actual = await vi.importActual('react-i18next') @@ -10,6 +11,10 @@ vi.mock('react-i18next', async () => { } }) +vi.mock('@tanstack/react-router', () => ({ + useNavigate: () => vi.fn(), +})) + vi.mock('@tanstack/react-query', () => ({ useQuery: () => ({ data: null }), useQueryClient: () => ({ invalidateQueries: vi.fn(), setQueryData: vi.fn() }), @@ -53,10 +58,16 @@ vi.mock('@/shared/ui/input', () => ({ Input: () => null, })) +import { renderToStaticMarkup } from 'react-dom/server' import { ProfileSettingsPage } from './profile' describe('ProfileSettingsPage', () => { it('exports a named component function', () => { expect(typeof ProfileSettingsPage).toBe('function') }) + + it('renders reset-password entry action', () => { + const html = renderToStaticMarkup(React.createElement(ProfileSettingsPage)) + expect(html).toContain('profile.resetPassword') + }) }) diff --git a/web/src/pages/settings/profile.tsx b/web/src/pages/settings/profile.tsx index 8fb07f7f4..dfb4a09c0 100644 --- a/web/src/pages/settings/profile.tsx +++ b/web/src/pages/settings/profile.tsx @@ -1,4 +1,5 @@ import { useState } from 'react' +import { useNavigate } from '@tanstack/react-router' import { useTranslation } from 'react-i18next' import { useQuery, useQueryClient } from '@tanstack/react-query' import { ApiError, profileApi } from '@/api/client' @@ -38,6 +39,7 @@ function getFieldValue( export function ProfileSettingsPage() { const { t } = useTranslation() const { user } = useAuth() + const navigate = useNavigate() const queryClient = useQueryClient() const [isEditing, setIsEditing] = useState(false) @@ -180,10 +182,17 @@ export function ProfileSettingsPage() { {t('profile.title')} {t('profile.subtitle')}
- {!isEditing && hasEditableFields ? ( - + {!isEditing ? ( +
+ + {hasEditableFields ? ( + + ) : null} +
) : null} diff --git a/web/src/pages/skill-detail.test.tsx b/web/src/pages/skill-detail.test.tsx index deed65d32..87bdcb487 100644 --- a/web/src/pages/skill-detail.test.tsx +++ b/web/src/pages/skill-detail.test.tsx @@ -2,10 +2,17 @@ import { renderToStaticMarkup } from 'react-dom/server' import { beforeEach, describe, expect, it, vi } from 'vitest' const navigateMock = vi.fn() -const hasRoleMock = vi.fn((role: string) => role === 'USER') +const hasRoleMock = vi.fn<(role: string) => boolean>((role: string) => role === 'USER') const useSkillDetailMock = vi.fn() const useSkillLabelsMock = vi.fn() const useSkillVersionsMock = vi.fn() +let authState: { + user: { userId: string; platformRoles: string[] } | null + hasRole: (role: string) => boolean +} = { + user: { userId: 'owner-1', platformRoles: ['USER'] }, + hasRole: hasRoleMock, +} vi.mock('@tanstack/react-router', () => ({ useNavigate: () => navigateMock, @@ -32,10 +39,7 @@ vi.mock('@tanstack/react-query', () => ({ })) vi.mock('@/features/auth/use-auth', () => ({ - useAuth: () => ({ - user: { userId: 'owner-1', platformRoles: ['USER'] }, - hasRole: hasRoleMock, - }), + useAuth: () => authState, })) vi.mock('@/features/report/use-skill-reports', () => ({ @@ -112,6 +116,8 @@ vi.mock('@/shared/hooks/use-skill-queries', () => ({ useRereleaseSkillVersion: () => ({ mutateAsync: vi.fn(), isPending: false }), useUnarchiveSkill: () => ({ mutateAsync: vi.fn(), isPending: false }), useWithdrawSkillReview: () => ({ mutateAsync: vi.fn(), isPending: false }), + useSubmitForReview: () => ({ mutateAsync: vi.fn(), isPending: false }), + useConfirmPublish: () => ({ mutateAsync: vi.fn(), isPending: false }), })) vi.mock('@/shared/hooks/use-label-queries', () => ({ @@ -163,6 +169,10 @@ describe('SkillDetailPage', () => { beforeEach(() => { navigateMock.mockReset() hasRoleMock.mockImplementation((role: string) => role === 'USER') + authState = { + user: { userId: 'owner-1', platformRoles: ['USER'] }, + hasRole: hasRoleMock, + } useSkillDetailMock.mockReturnValue({ data: createSkill(), isLoading: false, @@ -206,6 +216,31 @@ describe('SkillDetailPage', () => { expect(html).not.toContain('skillDetail.deleteSkill') }) + it('renders public skill details for an anonymous viewer', () => { + authState = { + user: null, + hasRole: vi.fn(() => false), + } + + useSkillDetailMock.mockReturnValue({ + data: createSkill({ + canManageLifecycle: false, + canInteract: true, + visibility: 'PUBLIC', + }), + isLoading: false, + isFetching: false, + error: null, + }) + + const html = renderToStaticMarkup() + + expect(html).toContain('Demo Skill') + expect(html).toContain('install') + expect(html).not.toContain('skillDetail.loginRequired') + expect(html).not.toContain('skillDetail.deleteSkill') + }) + it('shows the label management panel for a user who can manage the skill lifecycle', () => { useSkillDetailMock.mockReturnValue({ data: createSkill({ diff --git a/web/src/pages/skill-detail.tsx b/web/src/pages/skill-detail.tsx index 990396b4c..19f649835 100644 --- a/web/src/pages/skill-detail.tsx +++ b/web/src/pages/skill-detail.tsx @@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { useParams, useNavigate, useRouterState, useSearch } from '@tanstack/react-router' import { useMutation, useQueryClient } from '@tanstack/react-query' -import { ArrowLeft, ArrowUpCircle, ChevronDown, ChevronUp, Clock, Folder, RefreshCw, ShieldCheck, Terminal, User } from 'lucide-react' +import { ArrowLeft, ArrowUpCircle, ChevronDown, ChevronUp, Clock, Folder, Globe, Lock, RefreshCw, ShieldCheck, Terminal, User, Users } from 'lucide-react' import { MarkdownRenderer } from '@/features/skill/markdown-renderer' import { FileTree } from '@/features/skill/file-tree' import { FilePreviewDialog } from '@/features/skill/file-preview-dialog' @@ -16,6 +16,7 @@ import { shouldCollapseOverview, } from '@/features/skill/overview-collapse' import { resolveSkillActionErrorTitle } from '@/features/skill/skill-action-error' +import { isPrecheckConfirmationMessage, extractPrecheckWarnings } from '@/features/publish/publish-error-utils' import { clearDeletedSkillQueries, isDeleteSlugConfirmationValid, resolveDeletedSkillReturnTo } from '@/features/skill/skill-delete-flow' import { isSkillDetailQueriesEnabled } from './skill-detail-query' import { RatingInput } from '@/features/social/rating-input' @@ -53,6 +54,8 @@ import { useRereleaseSkillVersion, useUnarchiveSkill, useWithdrawSkillReview, + useSubmitForReview, + useConfirmPublish, } from '@/shared/hooks/use-skill-queries' import { useSubmitPromotion } from '@/shared/hooks/use-user-queries' @@ -114,7 +117,11 @@ export function SkillDetailPage() { const [withdrawVersionTarget, setWithdrawVersionTarget] = useState(null) const [rereleaseTarget, setRereleaseTarget] = useState(null) const [targetVersionInput, setTargetVersionInput] = useState('') + const [rereleaseWarnings, setRereleaseWarnings] = useState([]) + const [rereleaseWarningDialogOpen, setRereleaseWarningDialogOpen] = useState(false) const [diffSourceVersion, setDiffSourceVersion] = useState(null) + const [confirmPublishTarget, setConfirmPublishTarget] = useState(null) + const [submitReviewTarget, setSubmitReviewTarget] = useState(null) const [diffCompareVersion, setDiffCompareVersion] = useState(null) const [isOverviewExpanded, setIsOverviewExpanded] = useState(false) const [isOverviewCollapsible, setIsOverviewCollapsible] = useState(false) @@ -260,6 +267,8 @@ export function SkillDetailPage() { const rereleaseVersionMutation = useRereleaseSkillVersion() const submitPromotionMutation = useSubmitPromotion() const reportMutation = useSubmitSkillReport(namespace, slug) + const submitForReviewMutation = useSubmitForReview() + const confirmPublishMutation = useConfirmPublish() const triggerBrowserDownload = (url: string) => { const link = document.createElement('a') @@ -380,6 +389,7 @@ export function SkillDetailPage() { DRAFT: t('skillDetail.versionStatusDraft'), SCANNING: t('skillDetail.versionStatusScanning'), SCAN_FAILED: t('skillDetail.versionStatusScanFailed'), + UPLOADED: t('skillDetail.versionStatusUploaded'), PENDING_REVIEW: t('skillDetail.versionStatusPendingReview'), PUBLISHED: t('skillDetail.versionStatusPublished'), REJECTED: t('skillDetail.versionStatusRejected'), @@ -388,7 +398,7 @@ export function SkillDetailPage() { return status ? (map[status] ?? status) : '' } - const canDeleteVersion = (status?: string) => status === 'DRAFT' || status === 'REJECTED' || status === 'SCAN_FAILED' + const canDeleteVersion = (status?: string) => status === 'DRAFT' || status === 'REJECTED' || status === 'SCAN_FAILED' || status === 'UPLOADED' const isLastVersion = versions?.length === 1 const canWithdrawVersion = (status?: string) => status === 'PENDING_REVIEW' const canRereleaseVersion = (status?: string) => status === 'PUBLISHED' @@ -529,12 +539,46 @@ export function SkillDetailPage() { } } + const handleConfirmPublish = async () => { + if (!confirmPublishTarget) { + return + } + try { + await confirmPublishMutation.mutateAsync({ namespace, slug, version: confirmPublishTarget }) + toast.success( + t('skillDetail.confirmPublishSuccessTitle'), + t('skillDetail.confirmPublishSuccessDescription', { version: confirmPublishTarget }), + ) + setConfirmPublishTarget(null) + } catch (error) { + toast.error(t('skillDetail.confirmPublishErrorTitle'), error instanceof Error ? error.message : '') + throw error + } + } + + const handleSubmitForReview = async () => { + if (!submitReviewTarget) { + return + } + try { + await submitForReviewMutation.mutateAsync({ namespace, slug, version: submitReviewTarget, targetVisibility: 'PUBLIC' }) + toast.success( + t('skillDetail.submitReviewSuccessTitle'), + t('skillDetail.submitReviewSuccessDescription', { version: submitReviewTarget }), + ) + setSubmitReviewTarget(null) + } catch (error) { + toast.error(t('skillDetail.submitReviewErrorTitle'), error instanceof Error ? error.message : '') + throw error + } + } + const handleOpenRerelease = (version: string) => { setRereleaseTarget(version) setTargetVersionInput(suggestNextVersion(version)) } - const handleRereleaseVersion = async () => { + const handleRereleaseVersion = async (confirmWarnings = false) => { if (!rereleaseTarget || !targetVersionInput.trim()) { return } @@ -544,6 +588,7 @@ export function SkillDetailPage() { slug, version: rereleaseTarget, targetVersion: targetVersionInput.trim(), + confirmWarnings, }) toast.success( t('skillDetail.rereleaseSuccessTitle'), @@ -551,7 +596,15 @@ export function SkillDetailPage() { ) setRereleaseTarget(null) setTargetVersionInput('') + setRereleaseWarnings([]) + setRereleaseWarningDialogOpen(false) } catch (error) { + if (error instanceof ApiError && isPrecheckConfirmationMessage(error.serverMessage)) { + const warnings = extractPrecheckWarnings(error.serverMessage) + setRereleaseWarnings(warnings) + setRereleaseWarningDialogOpen(true) + return + } toast.error(t('skillDetail.rereleaseErrorTitle'), error instanceof Error ? error.message : '') throw error } @@ -669,10 +722,31 @@ export function SkillDetailPage() {
{skill.status && ( - + {resolveSkillStatusLabel(skill.status)} )} + {skill.visibility && ( + + {skill.visibility === 'PUBLIC' && } + {skill.visibility === 'PRIVATE' && } + {skill.visibility === 'NAMESPACE_ONLY' && } + {skill.visibility === 'PUBLIC' && t('publish.visibilityOptions.public')} + {skill.visibility === 'PRIVATE' && t('publish.visibilityOptions.private')} + {skill.visibility === 'NAMESPACE_ONLY' && t('publish.visibilityOptions.namespaceOnly')} + + )} {isReviewFlowPending && ( {t('skillDetail.versionStatusPendingReview')} @@ -884,6 +958,24 @@ export function SkillDetailPage() { {t('skillDetail.withdrawReview')} )} + {skill.canManageLifecycle && version.status === 'UPLOADED' && skill.visibility === 'PRIVATE' && ( + + )} + {skill.canManageLifecycle && version.status === 'UPLOADED' && skill.visibility === 'PRIVATE' && ( + + )}
{version.changelog && ( @@ -898,7 +990,7 @@ export function SkillDetailPage() { ))} ) : ( -
{t('skillDetail.noVersions')}
+ {t('skillDetail.noVersions')} )} @@ -1337,6 +1429,8 @@ export function SkillDetailPage() { if (!open) { setRereleaseTarget(null) setTargetVersionInput('') + setRereleaseWarnings([]) + setRereleaseWarningDialogOpen(false) } }} > @@ -1363,13 +1457,60 @@ export function SkillDetailPage() { - + +

{t('skillDetail.rereleaseWarningDescription')}

+
    + {rereleaseWarnings.map((warning, index) => ( +
  • {warning}
  • + ))} +
+ + } + confirmText={t('skillDetail.rereleaseWarningConfirm')} + onConfirm={() => { + setRereleaseWarningDialogOpen(false) + handleRereleaseVersion(true) + }} + /> + + { + if (!open) { + setConfirmPublishTarget(null) + } + }} + title={t('skillDetail.confirmPublishDialogTitle')} + description={confirmPublishTarget ? t('skillDetail.confirmPublishDialogDescription', { version: confirmPublishTarget }) : ''} + confirmText={t('skillDetail.confirmPublish')} + onConfirm={handleConfirmPublish} + /> + + { + if (!open) { + setSubmitReviewTarget(null) + } + }} + title={t('skillDetail.submitReviewDialogTitle')} + description={submitReviewTarget ? t('skillDetail.submitReviewDialogDescription', { version: submitReviewTarget }) : ''} + confirmText={t('skillDetail.submitReview')} + onConfirm={handleSubmitForReview} + /> + { diff --git a/web/src/shared/components/user-menu.tsx b/web/src/shared/components/user-menu.tsx index d1a9cec04..f0281a83d 100644 --- a/web/src/shared/components/user-menu.tsx +++ b/web/src/shared/components/user-menu.tsx @@ -3,6 +3,8 @@ import { useTranslation } from 'react-i18next' import { Link } from '@tanstack/react-router' import { useQueryClient } from '@tanstack/react-query' import { authApi } from '@/api/client' +import { useMyNamespaces } from '@/shared/hooks/use-namespace-queries' +import { buildGlobalReviewsPath, canAccessReviewCenter } from '@/features/review/review-paths' import { clearSessionScopedQueries } from '@/features/notification/notification-session' import { canViewGovernanceCenter } from '@/shared/lib/governance-access' import { cn } from '@/shared/lib/utils' @@ -22,19 +24,19 @@ interface UserMenuProps { export function UserMenu({ user, triggerClassName }: UserMenuProps) { const { t } = useTranslation() const queryClient = useQueryClient() + const { data: myNamespaces } = useMyNamespaces() const rootRef = useRef(null) const closeTimerRef = useRef(null) const [isHovered, setIsHovered] = useState(false) const [isClickOpen, setIsClickOpen] = useState(false) const hasRole = (role: string) => user.platformRoles?.includes(role) ?? false - const isReviewer = hasRole('SKILL_ADMIN') || hasRole('NAMESPACE_ADMIN') || hasRole('SUPER_ADMIN') const canSeeGovernance = canViewGovernanceCenter(user.platformRoles) const isSkillAdmin = hasRole('SKILL_ADMIN') || hasRole('SUPER_ADMIN') const isUserAdmin = hasRole('USER_ADMIN') || hasRole('SUPER_ADMIN') const isAuditor = hasRole('AUDITOR') || hasRole('SUPER_ADMIN') const isSuperAdmin = hasRole('SUPER_ADMIN') - const canAccessReviewCenter = isReviewer || isUserAdmin + const reviewCenterVisible = canAccessReviewCenter(user.platformRoles, myNamespaces) const isLocalAccount = !user.oauthProvider const open = isHovered || isClickOpen @@ -157,8 +159,8 @@ export function UserMenu({ user, triggerClassName }: UserMenuProps) { {t('user.menu.stars')} - {canAccessReviewCenter ? ( - + {reviewCenterVisible ? ( + {t('user.menu.reviews')} ) : null} diff --git a/web/src/shared/hooks/use-skill-queries.ts b/web/src/shared/hooks/use-skill-queries.ts index fb2458194..cf19d18cc 100644 --- a/web/src/shared/hooks/use-skill-queries.ts +++ b/web/src/shared/hooks/use-skill-queries.ts @@ -37,11 +37,12 @@ async function getSkillDocumentation(namespace: string, slug: string, version: s return fetchText(`${WEB_API_PREFIX}/skills/${cleanNamespace}/${encodeURIComponent(slug)}/versions/${encodeURIComponent(version)}/file?path=${encodeURIComponent(path)}`) } -async function publishSkill(params: { namespace: string; file: File; visibility: string }): Promise { +async function publishSkill(params: { namespace: string; file: File; visibility: string; confirmWarnings?: boolean }): Promise { const cleanNamespace = params.namespace.startsWith('@') ? params.namespace.slice(1) : params.namespace const formData = new FormData() formData.append('file', params.file) formData.append('visibility', params.visibility) + formData.append('confirmWarnings', String(params.confirmWarnings === true)) return fetchJson(`${WEB_API_PREFIX}/skills/${cleanNamespace}/publish`, { method: 'POST', @@ -55,7 +56,7 @@ export function useSearchSkills(params: SearchParams) { return useQuery({ queryKey: ['skills', 'search', params], queryFn: () => searchSkills(params), - enabled: params.starredOnly !== true && Boolean(params.q || params.label), + enabled: params.starredOnly !== true, }) } @@ -205,8 +206,49 @@ export function useRereleaseSkillVersion() { const queryClient = useQueryClient() return useMutation({ - mutationFn: ({ namespace, slug, version, targetVersion }: { namespace: string; slug: string; version: string; targetVersion: string }) => - skillLifecycleApi.rereleaseVersion(namespace, slug, version, targetVersion), + mutationFn: ({ namespace, slug, version, targetVersion, confirmWarnings }: { namespace: string; slug: string; version: string; targetVersion: string; confirmWarnings?: boolean }) => + skillLifecycleApi.rereleaseVersion(namespace, slug, version, targetVersion, confirmWarnings), + meta: { + skipGlobalErrorHandler: true, + }, + onSuccess: (_data, variables) => { + queryClient.invalidateQueries({ queryKey: ['skills', 'my'] }) + queryClient.invalidateQueries({ queryKey: ['skills', variables.namespace, variables.slug] }) + queryClient.invalidateQueries({ queryKey: ['skills', variables.namespace, variables.slug, 'versions'] }) + queryClient.invalidateQueries({ queryKey: ['skills'] }) + }, + }) +} + +/** + * Submit an UPLOADED version for review. + * Transitions version status from UPLOADED to PENDING_REVIEW. + */ +export function useSubmitForReview() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: ({ namespace, slug, version, targetVisibility }: { namespace: string; slug: string; version: string; targetVisibility: 'PUBLIC' | 'NAMESPACE_ONLY' }) => + skillLifecycleApi.submitForReview(namespace, slug, version, targetVisibility), + onSuccess: (_data, variables) => { + queryClient.invalidateQueries({ queryKey: ['skills', 'my'] }) + queryClient.invalidateQueries({ queryKey: ['skills', variables.namespace, variables.slug] }) + queryClient.invalidateQueries({ queryKey: ['skills', variables.namespace, variables.slug, 'versions'] }) + queryClient.invalidateQueries({ queryKey: ['skills'] }) + }, + }) +} + +/** + * Confirm publish for a PRIVATE skill version. + * Transitions version status from UPLOADED to PUBLISHED without review. + */ +export function useConfirmPublish() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: ({ namespace, slug, version }: { namespace: string; slug: string; version: string }) => + skillLifecycleApi.confirmPublish(namespace, slug, version), onSuccess: (_data, variables) => { queryClient.invalidateQueries({ queryKey: ['skills', 'my'] }) queryClient.invalidateQueries({ queryKey: ['skills', variables.namespace, variables.slug] })