测试用例补充并修复问题
This commit is contained in:
@@ -1,478 +1,348 @@
|
||||
# Jog System 自动化测试计划
|
||||
# Jog System 自动化测试规范(AI 测试用例唯一输入)
|
||||
|
||||
| 项目 | 内容 |
|
||||
> **更新**:需求级**全量展开**且文件名含 Cursor 的单一输入见 [`jog-system-cursor-automated-test-spec.md`](jog-system-cursor-automated-test-spec.md)。本文档保留为精简版路由与执行现状摘要。
|
||||
|
||||
| 属性 | 内容 |
|
||||
| :--- | :--- |
|
||||
| 文档版本 | V1.0 |
|
||||
| 文档版本 | V2.0 |
|
||||
| 生成日期 | 2026-05-18 |
|
||||
| 适用范围 | Jog System 后端 Maven 多模块、Vue 3 前端、数据库迁移与部署验证 |
|
||||
| 测试目标 | 建立可持续运行的自动化测试体系,覆盖核心业务、接口契约、安全控制、前端流程和发布质量门禁 |
|
||||
| 用途 | **仅依赖本文档**即可推导可执行的自动化测试用例(单元 / 集成 / 契约 / E2E / 安全 / 性能冒烟);业务验收口径同时映射需求文档中的 AC 编号 |
|
||||
| 仓库实际模块名 | `jog-common`、`jog-framework`、`jog-module-system`、`jog-module-blog`、`jog-module-comment`、`jog-admin`、`jog-frontend` |
|
||||
| 技术栈摘要 | 后端 Spring Boot 3.4.x、Java 21、Spring Security + JWT、MyBatis-Plus、MySQL、Redis、Flyway、SpringDoc;前端 Vue 3、Vite 6、Element Plus、Pinia、Axios |
|
||||
|
||||
> **说明**:历史文档 `jog-system-technical-design.md` 中的路径示例(如 `/api/app/blog/articles`)与当前代码不一致时,**以本文档「附录 A」为准**(来自当前 Controller 扫描)。需求规格中以 `AC-*` 编号的验收场景,在「第 7 节」逐条映射到可自动化用例骨架。
|
||||
|
||||
---
|
||||
|
||||
## 1. 项目扫描结论
|
||||
## 1. 当前已执行的测试与验证(事实记录)
|
||||
|
||||
### 1.1 技术与模块
|
||||
以下内容来自对仓库的一次质量核验(详见 `doc/jog-system-bug-list.md`),代表**截至文档日期的真实执行状況**,而非目标态。
|
||||
|
||||
本项目为前后端分离的博客系统:
|
||||
### 1.1 已执行项
|
||||
|
||||
- 后端:Spring Boot 3.4.5、Java 21、Maven 多模块、Spring Security + JWT、MyBatis-Plus、MySQL、Redis、Flyway、SpringDoc OpenAPI。
|
||||
- 前端:Vue 3.5、Vite 6、Element Plus、Pinia、Vue Router、Axios、Tiptap。
|
||||
- 模块结构:
|
||||
- `jog-common`:统一响应、分页模型、异常、枚举、限流注解等公共能力。
|
||||
- `jog-framework`:安全配置、JWT、鉴权过滤器、XSS 过滤、限流切面、全局异常。
|
||||
- `jog-module-system`:登录、注册、用户信息、管理员用户管理。
|
||||
- `jog-module-blog`:文章、分类、标签、点赞、公开列表、后台文章管理。
|
||||
- `jog-module-comment`:评论、回复、点赞、审核、后台评论管理。
|
||||
- `jog-admin`:启动入口、应用配置、Flyway 数据库迁移。
|
||||
- `jog-frontend`:前台浏览、登录注册、个人中心、文章编辑、后台管理页面。
|
||||
| 序号 | 类别 | 命令或方式 | 结果摘要 |
|
||||
| :---: | :--- | :--- | :--- |
|
||||
| T-01 | 后端编译链路 | `mvn -B test` | Maven 生命周期通过;**各模块均无自有 `src/test`,Surefire 报告为 `No tests to run`**(无 JUnit 级自动化回归) |
|
||||
| T-02 | 前端生产构建 | `jog-frontend` 下 `npm run build` | **Node v16.20.2**:Vite 报错 `crypto.getRandomValues is not a function`,构建失败。**Node v24.15.0**:构建通过,产物按项目配置输出到 `jog-admin/src/main/resources/static` |
|
||||
| T-03 | 前端依赖与脚本 | `package.json` | 仅有 `dev` / `build` / `preview`;**无** `test:unit`、`test:e2e`、lint 脚本 |
|
||||
| T-04 | API 契约(静态) | 人工对比 `jog-frontend/src/api/*.js` 与后端 Controller | 曾发现路径/方法不一致;经修复后应以附录 A 与 OpenAPI 重新做**双向校验**(当前未纳入 CI 自动化) |
|
||||
| T-05 | 数据库映射(静态) | 对比实体 `@TableName` / `@TableField` 与 Flyway `V1__init_schema.sql` | 曾发现表名、字段不一致;修复记录见 BUG-003/BUG-004(**仍需带库集成测试**验证) |
|
||||
| T-06 | 安全配置(静态) | 对比 `JwtTokenProvider`、JWT 过滤器、`SecurityConfig` 与 Controller 前缀 | 曾发现角色 Claims、未保护路径等问题;部分已修复(见 BUG-005/006/007),**需自动化权限矩阵回归** |
|
||||
|
||||
### 1.2 当前测试基础
|
||||
### 1.2 明确未执行 / 缺失项
|
||||
|
||||
- 后端仅 `jog-admin` 显式引入 `spring-boot-starter-test`,其余业务模块未单独声明测试依赖。
|
||||
- 项目源码中未发现自有 `src/test` 测试目录。
|
||||
- 前端 `package.json` 当前仅包含 `dev`、`build`、`preview`,未配置单元测试、组件测试、E2E 测试或 lint 脚本。
|
||||
- `node_modules` 已存在于工作区,属于依赖产物,不纳入源码测试范围。
|
||||
|
||||
### 1.3 扫描发现的测试重点
|
||||
|
||||
- 后端 Controller 实际路径为 `/api/auth`、`/api/article`、`/api/comment`、`/api/admin/**`、`/api/public/**` 等。
|
||||
- 前端 API 封装中存在 `/api/public/user/login`、`/api/app/article/mine`、`/api/app/comment/**` 等路径,与当前后端 Controller 的部分路径不一致,应将“前后端接口契约一致性”列为首要自动化检查。
|
||||
- `SecurityConfig` 中 `/api/admin/**` 需要 `ADMIN` 角色,`/api/app/**` 需要 `USER` 或 `ADMIN`,但部分业务 Controller 未挂在 `/api/app` 下,需通过安全回归测试确认实际权限边界。
|
||||
- `application.yml` 中含真实数据库、Redis 地址和密码样式配置,测试环境应改用隔离配置与环境变量,禁止自动化测试直接访问生产或公网中间件。
|
||||
- 需求文档与当前代码实现存在差异,例如验证码、邮箱验证、多端会话上限、评论三级嵌套、图片上传、关注、通知、标签管理等能力并未完整落地,自动化测试应区分“已实现回归”和“需求缺口验证”。
|
||||
|
||||
---
|
||||
|
||||
## 2. 测试目标与质量门禁
|
||||
|
||||
### 2.1 测试目标
|
||||
|
||||
- 保证登录、注册、文章发布、文章浏览、点赞、评论、后台审核、分类和用户管理等核心链路稳定。
|
||||
- 保证 JWT 鉴权、角色权限、Token 刷新/登出、限流、XSS 防护、统一异常响应可持续回归。
|
||||
- 保证前后端 API 路径、请求方法、请求参数、响应结构一致。
|
||||
- 保证 Flyway 迁移脚本、实体映射、MyBatis-Plus 逻辑删除与分页查询可靠。
|
||||
- 保证前端路由守卫、表单校验、Axios 拦截器和关键页面交互可回归。
|
||||
|
||||
### 2.2 推荐质量门禁
|
||||
|
||||
| 阶段 | 必跑项 | 通过标准 |
|
||||
| :--- | :--- | :--- |
|
||||
| 本地提交前 | 后端单元测试、前端构建、前端单元测试 | 全部通过,无新增严重告警 |
|
||||
| 合并请求 | 后端集成测试、API 契约测试、前端组件测试、E2E 冒烟 | 全部通过,核心模块覆盖率达标 |
|
||||
| 发布前 | 全量 E2E、迁移脚本验证、安全回归、性能冒烟 | P0/P1 缺陷为 0,P2 有明确处理计划 |
|
||||
| 上线后 | 健康检查、核心链路巡检、日志错误扫描 | 首页/API/登录/发文/评论均可用 |
|
||||
|
||||
### 2.3 覆盖率目标
|
||||
|
||||
| 层级 | 建议目标 |
|
||||
| 序号 | 说明 |
|
||||
| :--- | :--- |
|
||||
| 后端 Service 单元测试 | 行覆盖率 >= 80%,分支覆盖率 >= 70% |
|
||||
| 后端 Controller/API 测试 | 核心接口 100% 覆盖,错误路径至少覆盖主要业务错误码 |
|
||||
| 安全与鉴权测试 | 所有公开、登录、普通用户、管理员接口权限矩阵 100% 覆盖 |
|
||||
| 前端工具/API/Store 单元测试 | 行覆盖率 >= 70% |
|
||||
| 前端关键页面组件测试 | 登录、注册、首页、详情、编辑器、后台列表页面全覆盖 |
|
||||
| E2E 测试 | 核心用户旅程 100% 覆盖,非核心后台功能冒烟覆盖 |
|
||||
| N-01 | **无任何** `src/test/java` 下的单元测试或集成测试(仓库扫描为 0 个测试类) |
|
||||
| N-02 | **无** Vitest / Playwright / Cypress 等前端或 E2E 套件 |
|
||||
| N-03 | **无** CI 中可重复的契约 diff(OpenAPI vs `src/api`)流水线步骤 |
|
||||
|
||||
---
|
||||
|
||||
## 3. 测试分层策略
|
||||
## 2. 测试目标与质量门禁(目标态)
|
||||
|
||||
### 3.1 后端单元测试
|
||||
### 2.1 目标
|
||||
|
||||
推荐工具:JUnit 5、Mockito、AssertJ、Spring Security Test。
|
||||
- 核心链路:认证、注册、Token 刷新、文章 CRUD 与列表、评论、分类/标签、后台用户管理与审核。
|
||||
- 横切能力:JWT 鉴权、角色(`ROLE_ADMIN` / `ROLE_USER`)、统一响应 `ApiResult`、分页 `PageResult`、限流、`XssFilter`、Flyway 迁移可重复执行。
|
||||
- **前后端契约**:HTTP 方法、路径、请求/响应 JSON 字段与前端封装一致。
|
||||
- **需求对齐**:凡本文档标记为 `(需求)` 的用例,应以 `doc/jog-system-requirements.md` 中同名 AC 为判定依据;标记为 `(实现差异)` 的,以当前代码为准并建议产品确认。
|
||||
|
||||
重点覆盖:
|
||||
### 2.2 建议门禁(落地后)
|
||||
|
||||
- `AuthServiceImpl`
|
||||
- 邮箱不存在、密码错误、账号锁定、登录成功。
|
||||
- 管理员登录的角色校验。
|
||||
- 注册邮箱唯一性、密码加密、默认用户状态。
|
||||
- Refresh Token 校验失败、用户不存在、刷新成功。
|
||||
- 登出时 Token 加入黑名单。
|
||||
- `UserServiceImpl`
|
||||
- 当前用户信息查询。
|
||||
- 用户列表分页、状态筛选。
|
||||
- 管理员重置密码、锁定/解锁用户。
|
||||
- `ArticleServiceImpl`
|
||||
- 创建草稿/发布文章、发布时间设置。
|
||||
- 更新文章、标签重建、作者权限校验。
|
||||
- 公开列表分页、分类/标签/关键词过滤。
|
||||
- 我的文章、后台文章列表。
|
||||
- 回收站、恢复、作者删除、管理员删除。
|
||||
- 浏览量递增、点赞/取消点赞、文章不存在。
|
||||
- `CategoryServiceImpl`
|
||||
- 分类创建、重名、更新、删除。
|
||||
- 启用分类列表排序。
|
||||
- `CommentServiceImpl`
|
||||
- 创建根评论和回复。
|
||||
- 父评论不存在、回复层级限制。
|
||||
- 评论列表根评论与回复排序。
|
||||
- 作者删除权限、管理员审核、点赞/取消点赞。
|
||||
- `RateLimitAspect`、`JwtTokenProvider`、`TokenBlacklistService`、`XssFilter`
|
||||
- 限流 Key 与过期时间。
|
||||
- Token 生成、解析、过期、非法签名。
|
||||
- 黑名单命中。
|
||||
- 常见 XSS payload 过滤。
|
||||
|
||||
### 3.2 后端集成测试
|
||||
|
||||
推荐工具:Spring Boot Test、MockMvc、Testcontainers(MySQL、Redis)、Flyway、spring-security-test。
|
||||
|
||||
重点覆盖:
|
||||
|
||||
- 使用 Testcontainers 启动隔离 MySQL/Redis,运行 Flyway 迁移。
|
||||
- 验证 `V1__init_schema.sql` 与 `V2__init_data.sql` 可重复初始化干净环境。
|
||||
- 验证实体 `@TableName` 与实际表名一致,重点关注 `sys_user`/`blog_article` 等表与代码实体映射。
|
||||
- 验证 MyBatis-Plus 分页插件、逻辑删除字段、唯一索引冲突、事务回滚。
|
||||
- 使用 MockMvc 对 Controller 执行端到端接口测试,断言 HTTP 状态、统一响应结构、业务错误码和数据库副作用。
|
||||
|
||||
### 3.3 API 契约测试
|
||||
|
||||
推荐工具:SpringDoc OpenAPI 导出、Schemathesis 或 Dredd、Postman/Newman、或自定义 OpenAPI diff。
|
||||
|
||||
契约测试必须覆盖:
|
||||
|
||||
- 后端 OpenAPI 与前端 `src/api/*.js` 中路径、方法、参数一致。
|
||||
- 统一响应结构 `ApiResult` 字段稳定:`code`、`message`、`data`。
|
||||
- 分页响应 `PageResult` 字段稳定:列表、总数、页码、页大小。
|
||||
- 401、403、429、业务异常响应格式一致。
|
||||
- Token 刷新接口路径一致。当前前端调用 `/api/public/refresh-token`,后端扫描到的是 `/api/auth/refresh`,需优先纳入失败用例。
|
||||
|
||||
### 3.4 前端单元与组件测试
|
||||
|
||||
推荐工具:Vitest、Vue Test Utils、jsdom、MSW、Testing Library。
|
||||
|
||||
重点覆盖:
|
||||
|
||||
- `utils/request.js`
|
||||
- 请求自动附加 `Authorization`。
|
||||
- 业务 `code !== 200` 的错误处理。
|
||||
- 401 清理本地 Token 并跳转登录。
|
||||
- 403、429、网络错误提示。
|
||||
- Token 刷新失败/成功后的重试逻辑。
|
||||
- `router/index.js`
|
||||
- 未登录访问 `/app/**` 和 `/admin/**` 跳转登录。
|
||||
- 登录后路由放行。
|
||||
- 页面标题设置。
|
||||
- 管理员路由需要补充前端角色校验测试;当前代码只检查 token,未检查 `requiresAdmin`。
|
||||
- 页面组件
|
||||
- `Login.vue`、`Register.vue`:表单校验、提交状态、错误提示、登录后跳转。
|
||||
- `Home.vue`:列表加载、分页、分类筛选、空状态。
|
||||
- `ArticleDetail.vue`:详情渲染、评论列表、点赞、评论提交。
|
||||
- `ArticleEditor.vue`:标题/内容校验、草稿/发布、分类标签选择、编辑回填。
|
||||
- 后台 `Users.vue`、`Articles.vue`、`Comments.vue`、`Categories.vue`:列表、筛选、分页、操作确认。
|
||||
|
||||
### 3.5 E2E 测试
|
||||
|
||||
推荐工具:Playwright。
|
||||
|
||||
建议用户旅程:
|
||||
|
||||
1. 访客打开首页,查看公开文章列表,进入文章详情。
|
||||
2. 新用户注册、登录、进入个人控制台。
|
||||
3. 用户创建草稿、发布文章、编辑文章、移入回收站、恢复文章、删除文章。
|
||||
4. 另一用户浏览文章、点赞文章、发表评论、回复评论、取消点赞。
|
||||
5. 管理员登录后台,查看用户列表、重置密码、锁定用户。
|
||||
6. 管理员查看文章列表、删除违规文章。
|
||||
7. 管理员查看评论列表、审核评论、删除评论。
|
||||
8. 分类创建、编辑、删除后,前台筛选结果同步变化。
|
||||
9. 未登录用户访问编辑页会被重定向到登录页,并在登录后返回原目标页。
|
||||
10. 普通用户尝试访问后台应被拒绝。
|
||||
|
||||
### 3.6 安全自动化测试
|
||||
|
||||
重点用例:
|
||||
|
||||
- JWT
|
||||
- 缺失 Token、伪造 Token、过期 Token、黑名单 Token。
|
||||
- 用户 Token 访问管理员接口返回 403。
|
||||
- 管理员 Token 访问后台接口成功。
|
||||
- 限流
|
||||
- 登录 60 秒内超过 5 次被限制。
|
||||
- 注册 1 小时内超过 3 次被限制。
|
||||
- 限流 Key 应区分 IP/用户/接口,避免全局误伤。
|
||||
- XSS
|
||||
- 文章和评论中提交 `<script>`、事件属性、`javascript:` 链接。
|
||||
- 富文本合法标签不应被过度清洗。
|
||||
- CORS/CSRF
|
||||
- CORS 允许策略在生产环境应收敛,自动化检查不允许 `*` 与凭证同时进入生产配置。
|
||||
- CSRF 已关闭,需确认仅用于 JWT 无状态 API。
|
||||
- 敏感信息
|
||||
- 扫描配置文件中数据库密码、Redis 密码、JWT 密钥、邮箱密码。
|
||||
- 自动化测试禁止连接 `application.yml` 中公网数据库和 Redis。
|
||||
|
||||
### 3.7 性能与可靠性测试
|
||||
|
||||
推荐工具:k6、JMeter 或 Gatling。
|
||||
|
||||
冒烟指标:
|
||||
|
||||
- 首页文章列表 P95 < 500ms。
|
||||
- 文章详情 P95 < 500ms。
|
||||
- 登录接口 P95 < 800ms。
|
||||
- 创建文章 P95 < 1000ms。
|
||||
- 评论列表 P95 < 500ms。
|
||||
- 50 并发用户下错误率 < 1%。
|
||||
|
||||
专项场景:
|
||||
|
||||
- 高频浏览文章详情时浏览量更新是否产生锁竞争或丢失更新。
|
||||
- 点赞/取消点赞并发操作是否出现负数计数或唯一索引冲突。
|
||||
- 评论列表在大量回复下的 N+1 查询问题。
|
||||
- 后台文章/评论/用户分页在万级数据下的响应时间。
|
||||
|
||||
---
|
||||
|
||||
## 4. 测试环境规划
|
||||
|
||||
### 4.1 环境分层
|
||||
|
||||
| 环境 | 用途 | 数据策略 |
|
||||
| :--- | :--- | :--- |
|
||||
| Local | 开发者快速回归 | H2 或 Testcontainers,测试结束自动销毁 |
|
||||
| CI | 合并请求质量门禁 | Testcontainers MySQL/Redis,固定种子数据 |
|
||||
| Staging | 发布前全量验证 | 与生产拓扑接近,脱敏数据 |
|
||||
| Prod Smoke | 上线后巡检 | 只执行只读或专用测试账号的轻量用例 |
|
||||
|
||||
### 4.2 测试配置要求
|
||||
|
||||
- 新增 `application-test.yml`,数据库和 Redis 全部来自 Testcontainers 或本地隔离实例。
|
||||
- Flyway 在测试环境启用,保证迁移脚本真实可用。
|
||||
- 禁止测试代码读取公网数据库地址、真实 Redis 密码、真实 SMTP。
|
||||
- 测试账号和测试文章使用固定前缀,例如 `auto_test_`,便于清理。
|
||||
- 前端 E2E 使用独立 `.env.test`,指向测试后端。
|
||||
|
||||
---
|
||||
|
||||
## 5. 测试数据设计
|
||||
|
||||
### 5.1 基础数据
|
||||
|
||||
| 类型 | 数据 |
|
||||
| 阶段 | 必跑项 |
|
||||
| :--- | :--- |
|
||||
| 管理员 | `admin@example.com`,角色 ADMIN |
|
||||
| 普通用户 A | `user_a@example.com`,角色 USER |
|
||||
| 普通用户 B | `user_b@example.com`,角色 USER |
|
||||
| 锁定用户 | `locked@example.com`,状态 LOCKED |
|
||||
| 分类 | 技术、生活、公告 |
|
||||
| 标签 | Java、Vue、测试、随笔 |
|
||||
| 文章 | 草稿、公开已发布、私密、回收站、已删除 |
|
||||
| 评论 | 根评论、一级回复、待审核、已屏蔽、已删除 |
|
||||
| 本地/PR | `mvn -B verify`(含单测+集成)、前端 `npm run build`、`npm run test:unit`、OpenAPI↔前端 API 契约检查 |
|
||||
| 发布前 | Testcontainers 集成测试全绿、核心 Playwright 冒烟、Flyway 干净库迁移 |
|
||||
| 上线后 | 部署清单 `doc/jog-system-deployment-checklist.md` 第 11 节选段自动化或半自动巡检 |
|
||||
|
||||
### 5.2 边界数据
|
||||
### 2.3 覆盖率期望(指导 AI 分优先级)
|
||||
|
||||
- 标题空、标题 200 字、标题 201 字。
|
||||
- 密码长度 5、6、20、21。
|
||||
- 评论内容空、1000 字、超过 1000 字。
|
||||
- 分页 `page=0`、`page=1`、`pageSize=1`、`pageSize=100`、`pageSize=101`。
|
||||
- 不存在的用户 ID、文章 ID、评论 ID、分类 ID。
|
||||
- 重复邮箱、重复分类名、重复点赞。
|
||||
- 富文本中包含合法 HTML、XSS payload、超长内容。
|
||||
- 后端:Service 核心分支优先;Controller/MockMvc 覆盖所有附录 A 中带「写操作」或「鉴权敏感」的接口。
|
||||
- 前端:`utils/request.js`(Token、401/403/429)、`router`(`requiresAuth` / `requiresAdmin`)、各业务 `src/api/*.js`。
|
||||
- E2E:访客可读、用户发文与评论、管理员审核与删文的最短路径。
|
||||
|
||||
---
|
||||
|
||||
## 6. 自动化用例清单
|
||||
## 3. 测试环境与数据约定
|
||||
|
||||
### 6.1 认证与用户
|
||||
### 3.1 运行时版本
|
||||
|
||||
| 编号 | 类型 | 场景 | 预期 |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| AUTH-001 | 单元/API | 正确邮箱密码登录 | 返回 accessToken、refreshToken、用户信息 |
|
||||
| AUTH-002 | 单元/API | 邮箱不存在 | 返回用户不存在错误,记录失败日志 |
|
||||
| AUTH-003 | 单元/API | 密码错误 | 返回密码错误,记录失败日志 |
|
||||
| AUTH-004 | 单元/API | 锁定用户登录 | 返回账号锁定 |
|
||||
| AUTH-005 | 单元/API | 普通用户调用管理员登录 | 返回权限不足 |
|
||||
| AUTH-006 | API | 注册新邮箱 | 用户入库,密码为 BCrypt |
|
||||
| AUTH-007 | API | 重复邮箱注册 | 返回邮箱已存在 |
|
||||
| AUTH-008 | API | 刷新 Token | 返回新的 accessToken 和 refreshToken |
|
||||
| AUTH-009 | API | 非法 refreshToken | 返回 Token 无效 |
|
||||
| AUTH-010 | API | 登出后使用旧 Token | 被拒绝或命中黑名单 |
|
||||
| USER-001 | API | 获取当前用户信息 | 返回登录用户资料 |
|
||||
| USER-002 | API | 管理员分页查询用户 | 返回分页结构 |
|
||||
| USER-003 | API | 管理员重置密码 | 新密码可登录,旧密码失效 |
|
||||
| USER-004 | API | 管理员锁定用户 | 被锁定用户无法登录 |
|
||||
| 组件 | 要求 |
|
||||
| :--- | :--- |
|
||||
| JDK | 21 |
|
||||
| Node.js | **≥ 20**(Vite 6 需要现代 Web Crypto;以构建成功为准,建议在 `package.json` 增加 `engines` 并在 CI 锁定) |
|
||||
| MySQL / Redis | 集成测试推荐使用 Testcontainers;**禁止**让自动化测试默认连接开发机 `application.yml` 中的真实公网地址 |
|
||||
|
||||
### 6.2 文章与分类标签
|
||||
### 3.2 测试 Profile
|
||||
|
||||
| 编号 | 类型 | 场景 | 预期 |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| BLOG-001 | 单元/API | 创建草稿 | 状态为草稿,无发布时间 |
|
||||
| BLOG-002 | 单元/API | 发布文章 | 状态为已发布,生成发布时间 |
|
||||
| BLOG-003 | 单元/API | 更新文章标签 | 旧关联删除,新关联创建 |
|
||||
| BLOG-004 | API | 非作者更新文章 | 返回权限不足 |
|
||||
| BLOG-005 | API | 公开列表仅显示公开已发布 | 草稿、私密、回收站不出现 |
|
||||
| BLOG-006 | API | 按分类筛选 | 只返回该分类文章 |
|
||||
| BLOG-007 | API | 按标签筛选 | 只返回该标签文章 |
|
||||
| BLOG-008 | API | 关键词搜索 | 标题或摘要匹配 |
|
||||
| BLOG-009 | API | 我的文章列表 | 只返回当前用户文章 |
|
||||
| BLOG-010 | API | 移入回收站 | 状态变为回收站 |
|
||||
| BLOG-011 | API | 恢复文章 | 状态变为草稿 |
|
||||
| BLOG-012 | API | 点赞文章 | 点赞记录创建,计数 +1 |
|
||||
| BLOG-013 | API | 取消点赞 | 点赞记录删除,计数 -1 且不为负 |
|
||||
| BLOG-014 | API | 后台文章列表 | 管理员可按状态/分类/关键词筛选 |
|
||||
| CAT-001 | API | 创建分类 | 分类入库,可在公开列表读取 |
|
||||
| CAT-002 | API | 重名分类 | 返回唯一性错误 |
|
||||
| CAT-003 | API | 更新分类 | 名称、描述、排序生效 |
|
||||
| CAT-004 | API | 删除分类 | 分类不可再查询或被逻辑处理 |
|
||||
- 使用 `application-test.yml`:数据源与 Redis 来自容器或临时实例;Flyway 开启;JWT 密钥固定测试值。
|
||||
- 种子用户(示例):
|
||||
- 管理员:`admin@example.com` 或 Flyway 初始管理员(以 `V2__init_data.sql` 为准);
|
||||
- 普通用户 A/B:用于交叉评论、点赞;
|
||||
- 锁定用户:用于登录拒绝场景。
|
||||
|
||||
### 6.3 评论
|
||||
### 3.3 数据命名
|
||||
|
||||
| 编号 | 类型 | 场景 | 预期 |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| CMT-001 | 单元/API | 创建根评论 | 评论状态为通过,文章 ID 正确 |
|
||||
| CMT-002 | 单元/API | 回复根评论 | parentId 正确 |
|
||||
| CMT-003 | 单元/API | 回复二级评论 | 返回层级限制错误 |
|
||||
| CMT-004 | API | 非作者删除评论 | 返回权限不足 |
|
||||
| CMT-005 | API | 作者删除评论 | 评论被删除 |
|
||||
| CMT-006 | API | 评论列表 | 根评论倒序,回复正序 |
|
||||
| CMT-007 | API | 点赞评论 | 点赞记录创建,计数 +1 |
|
||||
| CMT-008 | API | 取消点赞评论 | 点赞记录删除,计数 -1 且不为负 |
|
||||
| CMT-009 | API | 管理员分页查询评论 | 返回分页结构 |
|
||||
| CMT-010 | API | 管理员审核评论 | 评论状态更新 |
|
||||
| CMT-011 | API | 管理员删除评论 | 评论不可见 |
|
||||
|
||||
### 6.4 前端页面与交互
|
||||
|
||||
| 编号 | 类型 | 场景 | 预期 |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| FE-001 | 单元 | Axios 自动附加 Token | 请求头包含 Bearer Token |
|
||||
| FE-002 | 单元 | 业务响应 code 非 200 | 展示错误并 reject |
|
||||
| FE-003 | 单元 | 401 响应 | 清理本地登录态并跳转登录 |
|
||||
| FE-004 | 单元 | 未登录访问 `/app/dashboard` | 跳转登录并携带 redirect |
|
||||
| FE-005 | 单元 | 未登录访问 `/admin` | 跳转登录 |
|
||||
| FE-006 | 组件 | 登录表单提交成功 | 保存 Token 并跳转 |
|
||||
| FE-007 | 组件 | 注册表单校验失败 | 阻止提交并提示 |
|
||||
| FE-008 | 组件 | 首页列表加载 | 渲染文章卡片、分页和空状态 |
|
||||
| FE-009 | 组件 | 文章详情加载 | 渲染正文、点赞、评论 |
|
||||
| FE-010 | 组件 | 编辑器发布文章 | 调用创建/更新 API |
|
||||
| FE-011 | 组件 | 后台列表操作 | 筛选、分页、删除/审核确认 |
|
||||
| FE-012 | 契约 | 前端 API 路径与后端 OpenAPI 对比 | 不允许出现未实现路径 |
|
||||
|
||||
### 6.5 安全与异常
|
||||
|
||||
| 编号 | 类型 | 场景 | 预期 |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| SEC-001 | API | 无 Token 访问需登录接口 | 返回 401 |
|
||||
| SEC-002 | API | 普通用户访问 `/api/admin/**` | 返回 403 |
|
||||
| SEC-003 | API | 管理员访问后台接口 | 成功 |
|
||||
| SEC-004 | API | 伪造 JWT | 返回 401 |
|
||||
| SEC-005 | API | 黑名单 JWT | 返回 401 |
|
||||
| SEC-006 | API | 登录限流 | 超限返回 429 或业务限流错误 |
|
||||
| SEC-007 | API | 注册限流 | 超限返回 429 或业务限流错误 |
|
||||
| SEC-008 | API | 文章提交 XSS | 脚本被过滤或拒绝 |
|
||||
| SEC-009 | API | 评论提交 XSS | 脚本被过滤或拒绝 |
|
||||
| SEC-010 | 静态扫描 | 配置文件敏感信息 | CI 阻断硬编码密码和密钥 |
|
||||
- 自动化创建的数据使用前缀 `auto_test_`,用例结束清理或事务回滚。
|
||||
|
||||
---
|
||||
|
||||
## 7. CI/CD 自动化建议
|
||||
## 4. 角色与鉴权预期
|
||||
|
||||
### 7.1 推荐脚本
|
||||
| 角色 | JWT Role(Spring `hasRole`) | 典型路径 |
|
||||
| :--- | :--- | :--- |
|
||||
| 匿名 | 无 | `/api/public/**`、静态资源、`/login`、`/register` |
|
||||
| 注册用户 | `ROLE_USER` | `/api/app/**` |
|
||||
| 管理员 | `ROLE_ADMIN` | `/api/admin/**` |
|
||||
|
||||
后端建议新增:
|
||||
**实现提示(来自代码静态阅读)**:
|
||||
|
||||
```bash
|
||||
mvn -B clean verify
|
||||
- `SecurityConfig` 同时包含 `.requestMatchers("/api/article/**", "/api/comment/**", "/api/user/**")`——若路由已统一至 `/api/app/**`,上述行可能为历史遗留;**自动化用例应验证「仅 /api/app/** 与 /api/admin/**」即可覆盖全部业务接口**,并检测是否仍存在未保护「遗留前缀」。
|
||||
- Token 内角色应与 `ROLE_ADMIN` / `ROLE_USER` 匹配(历史缺陷 BUG-005)。
|
||||
|
||||
---
|
||||
|
||||
## 5. 已知缺陷与约束(编写用例时必须考虑)
|
||||
|
||||
| ID | 描述 | 对测试的影响 |
|
||||
| :--- | :--- | :--- |
|
||||
| BUG-010(部分) | `/api/public/captcha` 等为**占位实现**,不做真实图形校验 | 用例应断言「接口存在且返回约定结构」,**不应**按真实验证码安全强度验收 |
|
||||
| BUG-014 | 限流 Key 维度不足,可能全站共享桶 | 限流用例需记录为**弱保证**;修复后增加 IP/用户维度断言 |
|
||||
| BUG-015 | 无单测/集成测 | 所有用例当前为**待实现**;本文件提供用例 ID 与步骤模板 |
|
||||
| 实现差异 | 需求规定访客可查看评论;当前评论列表在 `CommentController` 上位于 `/api/app/comment/**`,通常需要登录 | E2E 若模拟访客读评论:以实际 HTTP 状态为准;建议单独增加 `(实现差异)` 用例记录与需求偏差 |
|
||||
|
||||
---
|
||||
|
||||
## 6. 测试分层与工具选型(供生成代码时使用)
|
||||
|
||||
| 分层 | 工具 | 放置目录 |
|
||||
| :--- | :--- | :--- |
|
||||
| 后端单元 | JUnit 5、Mockito、AssertJ | 各模块 `src/test/java` |
|
||||
| 后端集成 | Spring Boot Test、MockMvc、`@SpringBootTest`、`@DynamicPropertySource` + Testcontainers(MySQL、Redis) | 建议集中在 `jog-admin/src/test/java` 或各模块 |
|
||||
| 契约 | SpringDoc 生成 OpenAPI YAML + 自定义脚本对比 `jog-frontend/src/api` | `scripts/` 或 CI |
|
||||
| 前端单元/组件 | Vitest、Vue Test Utils、MSW | `jog-frontend` |
|
||||
| E2E | Playwright | `jog-frontend/e2e/` 或仓库根 `e2e/` |
|
||||
| 性能冒烟 | k6 或 JMeter | `perf/`(可选) |
|
||||
|
||||
---
|
||||
|
||||
## 7. 需求验收矩阵 → 自动化用例映射
|
||||
|
||||
下列条目**直接服务** `doc/jog-system-requirements.md` 中的 AC。AI 生成用例时:保留 `需求追溯` 列;若实现不满足需求,将 `预期` 改为「记录实际行为 + xfail」。
|
||||
|
||||
| 用例 ID | 需求追溯 | 建议分层 | 角色 | 摘要 |
|
||||
| :--- | :--- | :--- | :--- | :--- |
|
||||
| R-ADM-LOGIN-01 | AC-ADM-LOGIN-01 | E2E / API | Admin | 正确凭据登录后台,返回 Token |
|
||||
| R-ADM-LOGIN-02 | AC-ADM-LOGIN-02 | API | Admin | 连续错误触发验证码流程(若已实现) |
|
||||
| R-ADM-LOGIN-03 | AC-ADM-LOGIN-03 | API | Admin | 连续错误锁定账号 |
|
||||
| R-USER-REG-01 | AC-USER-REG-01 | API | Anonymous | 合法邮箱注册 |
|
||||
| R-USER-REG-02 | AC-USER-REG-02 | API | Anonymous | 重复邮箱拒绝 |
|
||||
| R-USER-REG-03 | AC-USER-REG-03 | FE 单元 / E2E | Anonymous | 弱密码前端拦截 |
|
||||
| R-USER-REG-04 | AC-USER-REG-04 | API | Anonymous | 同 IP 注册限流 |
|
||||
| R-USER-LOGIN-01 | AC-USER-LOGIN-01 | E2E / API | User | 用户登录成功 |
|
||||
| R-USER-LOGIN-02 | AC-USER-LOGIN-02 | API / 集成 | User | 多会话驱逐最早 |
|
||||
| R-USER-LOGIN-03 | AC-USER-LOGIN-03 | API | User | 错误次数锁定 |
|
||||
| R-BLOG-PUB-01 | AC-BLOG-PUB-01 | E2E | Anonymous→User | 未登录跳转登录后可回跳编辑器 |
|
||||
| R-BLOG-PUB-04 | AC-BLOG-PUB-04 | API | User | 私密文章对非作者不可见 |
|
||||
| R-BLOG-LIST-01 | AC-BLOG-LIST-01 | API | All | 列表排序参数生效 |
|
||||
| R-BLOG-LIST-05 | AC-BLOG-LIST-05 | API | User / Author | 作者见私密、他人不见 |
|
||||
| R-BLOG-LIST-06 | AC-BLOG-LIST-06 | API | Author | 作者看自己文章详情浏览量不增 |
|
||||
| R-COMMENT-01 | AC-COMMENT-01 | E2E | Anonymous | 未登录评论入口拒绝 |
|
||||
| R-COMMENT-02 | AC-COMMENT-02 | API | User | 四级时折叠为三级平级 |
|
||||
| R-COMMENT-03 | AC-COMMENT-03 | API | Admin/User | 审核开关与评论状态联动 |
|
||||
| R-COMMENT-07 | AC-COMMENT-07 | API | User | XSS payload 不执行 |
|
||||
|
||||
(其余 AC 按同一模式扩展;性能与安全全局项见第 10、11 节。)
|
||||
|
||||
---
|
||||
|
||||
## 8. 接口级用例清单(核心回归)
|
||||
|
||||
**书写约定**:每条可展开为 Gherkin 或 JUnit `@DisplayName`。`code` 指业务码,默认成功 `200`(见项目统一约定)。
|
||||
|
||||
### 8.1 认证 `/api/public`
|
||||
|
||||
| ID | 方法 | 路径 | 角色 | 预期要点 |
|
||||
| :--- | :--- | :--- | :--- | :--- |
|
||||
| API-AUTH-001 | POST | `/api/public/user/login` | Anonymous | 成功:`data` 含 `accessToken`、`refreshToken` 或项目实际字段名 |
|
||||
| API-AUTH-002 | POST | `/api/public/admin/login` | Anonymous | 管理员成功;非管理员拒绝 |
|
||||
| API-AUTH-003 | POST | `/api/public/user/register` | Anonymous | 成功创建;重复邮箱失败 |
|
||||
| API-AUTH-004 | POST | `/api/public/refresh-token` | Anonymous | refresh 有效时换新 Token;无效时 401 |
|
||||
| API-AUTH-005 | GET | `/api/public/captcha` | Anonymous | 200 + 占位数据结构(BUG-010) |
|
||||
|
||||
### 8.2 用户 `/api/app/user`、`/api/admin/user`
|
||||
|
||||
| ID | 方法 | 路径 | 角色 | 预期要点 |
|
||||
| :--- | :--- | :--- | :--- | :--- |
|
||||
| API-USER-001 | GET | `/api/app/user/me` | User | 当前用户资料 |
|
||||
| API-USER-002 | POST | `/api/app/user/{id}/follow` | User | 关注成功 |
|
||||
| API-USER-003 | DELETE | `/api/app/user/{id}/follow` | User | 取关成功 |
|
||||
| API-USER-004 | POST | `/api/app/user/logout` | User | 会话或黑名单生效(按实现) |
|
||||
| API-USER-010 | GET | `/api/admin/user/list` | Admin | 分页 `PageResult` |
|
||||
| API-USER-011 | PUT | `/api/admin/user/{id}/reset-password` | Admin | 重置后可登录 |
|
||||
| API-USER-012 | PUT | `/api/admin/user/{id}/status` | Admin | 锁定后登录失败 |
|
||||
|
||||
### 8.3 文章(公开)`/api/public/article`
|
||||
|
||||
| ID | 方法 | 路径 | 角色 | 预期要点 |
|
||||
| :--- | :--- | :--- | :--- | :--- |
|
||||
| API-PUB-ART-001 | GET | `/api/public/article/list` | Anonymous | 仅公开已发布;分页字段齐全 |
|
||||
| API-PUB-ART-002 | GET | `/api/public/article/{id}` | Anonymous | 详情;标签为结构化 `Tag`(BUG-013 修复预期) |
|
||||
|
||||
### 8.4 文章(用户)`/api/app/article`
|
||||
|
||||
| ID | 方法 | 路径 | 角色 | 预期要点 |
|
||||
| :--- | :--- | :--- | :--- | :--- |
|
||||
| API-APP-ART-001 | POST | `/api/app/article` | User | 创建成功返回 id |
|
||||
| API-APP-ART-002 | PUT | `/api/app/article/{id}` | User | 作者可改;非作者 403 |
|
||||
| API-APP-ART-003 | GET | `/api/app/article/mine` | User | 仅本人文章 |
|
||||
| API-APP-ART-004 | PUT | `/api/app/article/{id}/recycle` | User | 回收站状态 |
|
||||
| API-APP-ART-005 | PUT | `/api/app/article/{id}/restore` | User | 恢复 |
|
||||
| API-APP-ART-006 | DELETE | `/api/app/article/{id}` | User | 删除 |
|
||||
| API-APP-ART-007 | POST | `/api/app/article/{id}/like` | User | 点赞切换 |
|
||||
|
||||
### 8.5 文章(管理)`/api/admin/article`
|
||||
|
||||
| ID | 方法 | 路径 | 角色 | 预期要点 |
|
||||
| :--- | :--- | :--- | :--- | :--- |
|
||||
| API-ADM-ART-001 | GET | `/api/admin/article/list` | Admin | 后台列表 |
|
||||
| API-ADM-ART-002 | DELETE | `/api/admin/article/{id}` | Admin | 管理员删除 |
|
||||
|
||||
### 8.6 分类 `/api/public/category`、`/api/admin/category`
|
||||
|
||||
| ID | 方法 | 路径 | 角色 | 预期要点 |
|
||||
| :--- | :--- | :--- | :--- | :--- |
|
||||
| API-CAT-001 | GET | `/api/public/category/list` | Anonymous | 列表 |
|
||||
| API-CAT-010 | POST | `/api/admin/category` | Admin | 创建 |
|
||||
| API-CAT-011 | PUT | `/api/admin/category/{id}` | Admin | 更新 |
|
||||
| API-CAT-012 | DELETE | `/api/admin/category/{id}` | Admin | 删除 |
|
||||
|
||||
### 8.7 标签 `/api/public/tag`、`/api/admin/tag`
|
||||
|
||||
| ID | 方法 | 路径 | 角色 | 预期要点 |
|
||||
| :--- | :--- | :--- | :--- | :--- |
|
||||
| API-TAG-001 | GET | `/api/public/tag/list` | Anonymous | 列表 |
|
||||
| API-TAG-010 | POST | `/api/admin/tag` | Admin | `name` 参数创建 |
|
||||
|
||||
### 8.8 评论 `/api/app/comment`、`/api/admin/comment`
|
||||
|
||||
| ID | 方法 | 路径 | 角色 | 预期要点 |
|
||||
| :--- | :--- | :--- | :--- | :--- |
|
||||
| API-CMT-001 | POST | `/api/app/comment` | User | 创建评论 |
|
||||
| API-CMT-002 | DELETE | `/api/app/comment/{id}` | User | 删除本人评论 |
|
||||
| API-CMT-003 | GET | `/api/app/comment/article/{articleId}` | User | 树形列表(**若需访客可读应另有公开接口—当前可能需登录**) |
|
||||
| API-CMT-004 | POST | `/api/app/comment/{id}/like` | User | 点赞切换 |
|
||||
| API-CMT-010 | GET | `/api/admin/comment/list` | Admin | 分页列表 |
|
||||
| API-CMT-011 | PUT | `/api/admin/comment/{id}/audit` | Admin | 审核 |
|
||||
| API-CMT-012 | DELETE | `/api/admin/comment/{id}` | Admin | 删除 |
|
||||
|
||||
---
|
||||
|
||||
## 9. 安全与负向用例(必测)
|
||||
|
||||
| ID | 场景 | 预期 |
|
||||
| :--- | :--- | :--- |
|
||||
| SEC-001 | 无 Token 调 `/api/app/article` POST | 401 |
|
||||
| SEC-002 | User Token 调 `/api/admin/user/list` | 403 |
|
||||
| SEC-003 | Admin Token 调 `/api/admin/**` | 200(合法操作下) |
|
||||
| SEC-004 | 篡改签名的 JWT | 401 |
|
||||
| SEC-005 | 已登出/黑名单 JWT | 401(若实现黑名单) |
|
||||
| SEC-006 | 登录接口暴力请求 | 429 或业务限流码 |
|
||||
| SEC-007 | 文章/评论 body 携带 `<script>` | 存储或展示侧无害化 |
|
||||
|
||||
---
|
||||
|
||||
## 10. 非功能与性能(源自于需求文档)
|
||||
|
||||
| ID | 类型 | 条件 | 工具建议 |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| NF-PERF-01 | 接口延迟 | 列表/详情 P95 ≤ 需求文档值 | k6 |
|
||||
| NF-PERF-02 | 前端首屏 | Lighthouse 4G ≤ 2s | Lighthouse CI |
|
||||
| NF-SEC-01 | 密码存储 | DB 中为 BCrypt | JDBC 断言 |
|
||||
| NF-AVAIL-01 | 并发 | 500 并发错误率 < 1% | k6 |
|
||||
|
||||
---
|
||||
|
||||
## 11. 前端专项(Vitest / Playwright)
|
||||
|
||||
| ID | 范围 | 场景 |
|
||||
| :--- | :--- | :--- |
|
||||
| FE-001 | `utils/request` | 请求携带 `Authorization` |
|
||||
| FE-002 | `utils/request` | `code !== 200` 时 reject |
|
||||
| FE-003 | `utils/request` | 401 清 token 并跳转登录 |
|
||||
| FE-004 | `router` | `requiresAuth` 拦截 |
|
||||
| FE-005 | `router` | `requiresAdmin` 非管理员重定向(BUG-011 相关) |
|
||||
| FE-E2E-01 | Playwright | 访客首页→详情 |
|
||||
| FE-E2E-02 | Playwright | 用户登录→发文→可见 |
|
||||
| FE-E2E-03 | Playwright | 管理员审核评论 |
|
||||
|
||||
---
|
||||
|
||||
## 12. Flyway 与持久化
|
||||
|
||||
| ID | 场景 | 预期 |
|
||||
| :--- | :--- | :--- |
|
||||
| DB-001 | 空库启动 | `V1`/`V2` 应用成功,`flyway_schema_history` 成功 |
|
||||
| DB-002 | 重复启动 | 迁移不失败 |
|
||||
| DB-003 | 实体 CRUD | 插入后按 `sys_*` / `blog_*` 表可读 |
|
||||
|
||||
---
|
||||
|
||||
## 13. AI 生成用例时的输出模板(强制)
|
||||
|
||||
每条用例应包含以下字段(JSON 或表格均可):
|
||||
|
||||
```text
|
||||
id: <本节已定义 ID>
|
||||
title: <短标题>
|
||||
layer: <unit|integration|contract|e2e>
|
||||
role: <anonymous|user|admin>
|
||||
preconditions: <数据与配置>
|
||||
steps:
|
||||
1. ...
|
||||
2. ...
|
||||
expected:
|
||||
http: <状态码>
|
||||
body: <关键 JSON 路径与值>
|
||||
side_effects: <数据库/Redis 可选>
|
||||
trace: <R-* / API-* / SEC-* / BUG-*>
|
||||
flaky_notes: <可选>
|
||||
```
|
||||
|
||||
前端建议新增:
|
||||
|
||||
```bash
|
||||
npm ci
|
||||
npm run test:unit
|
||||
npm run test:component
|
||||
npm run build
|
||||
npm run test:e2e
|
||||
```
|
||||
|
||||
### 7.2 推荐新增依赖与脚本
|
||||
|
||||
后端:
|
||||
|
||||
- 在父 POM 统一管理测试依赖:JUnit 5、Mockito、AssertJ、Testcontainers MySQL/Redis、Spring Security Test、JaCoCo。
|
||||
- 将测试依赖下沉到需要测试的模块,避免只有启动模块能写测试。
|
||||
- 配置 Maven Surefire/Failsafe,区分单元测试和集成测试。
|
||||
|
||||
前端:
|
||||
|
||||
- 新增 Vitest、Vue Test Utils、jsdom、MSW、Playwright。
|
||||
- 在 `package.json` 中新增:
|
||||
- `test:unit`
|
||||
- `test:component`
|
||||
- `test:e2e`
|
||||
- `test:e2e:ui`
|
||||
- `coverage`
|
||||
|
||||
### 7.3 Pipeline 阶段
|
||||
|
||||
1. 依赖安装与缓存。
|
||||
2. 后端编译与单元测试。
|
||||
3. 后端集成测试,启动 MySQL/Redis Testcontainers。
|
||||
4. 生成 OpenAPI 文档。
|
||||
5. 前端 API 封装与 OpenAPI 契约对比。
|
||||
6. 前端单元/组件测试。
|
||||
7. 前端生产构建。
|
||||
8. 启动完整应用,运行 Playwright E2E 冒烟。
|
||||
9. 上传测试报告、覆盖率报告和 E2E 截图/视频。
|
||||
|
||||
---
|
||||
|
||||
## 8. 缺陷分级与处理策略
|
||||
## 附录 A:当前后端路由一览(扫描自代码,2026-05-18)
|
||||
|
||||
| 等级 | 定义 | 示例 | 处理要求 |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| P0 | 系统不可用或核心数据损坏 | 无法启动、登录全失败、发文数据丢失 | 立即阻断发布 |
|
||||
| P1 | 核心业务不可用或严重安全问题 | 普通用户可访问后台、Token 绕过、评论 XSS | 阻断合并/发布 |
|
||||
| P2 | 重要功能异常但有规避方式 | 筛选错误、分页异常、后台单项操作失败 | 发布前修复或明确延期 |
|
||||
| P3 | 轻微体验或边界问题 | 文案不准、空状态异常、非核心兼容问题 | 排入迭代 |
|
||||
|
||||
---
|
||||
|
||||
## 9. 当前优先落地路线
|
||||
|
||||
### 第一阶段:补齐基础测试设施
|
||||
|
||||
- 后端新增 `src/test` 目录、`application-test.yml`、JUnit/Mockito/Testcontainers/JaCoCo。
|
||||
- 前端新增 Vitest、Vue Test Utils、MSW、Playwright。
|
||||
- 建立 CI 基础命令:后端 `mvn verify`,前端 `npm run build` + 单元测试。
|
||||
|
||||
### 第二阶段:先保护最容易出事故的链路
|
||||
|
||||
- 认证:登录、注册、刷新、登出、管理员权限。
|
||||
- 契约:前端 API 路径和后端 Controller/OpenAPI 对比。
|
||||
- 文章:创建、发布、列表、详情、更新、删除、点赞。
|
||||
- 评论:创建、列表、回复、删除、审核、点赞。
|
||||
|
||||
### 第三阶段:增强安全与发布信心
|
||||
|
||||
- XSS、限流、JWT 黑名单、CORS、敏感信息扫描。
|
||||
- Flyway 迁移全量验证。
|
||||
- Playwright 覆盖访客、普通用户、管理员三类角色端到端流程。
|
||||
- k6/JMeter 做核心接口性能冒烟。
|
||||
|
||||
---
|
||||
|
||||
## 10. 风险清单
|
||||
|
||||
| 风险 | 影响 | 建议 |
|
||||
| Controller 类 | 前缀 | 方法摘要 |
|
||||
| :--- | :--- | :--- |
|
||||
| 前后端 API 路径不一致 | 前端页面调用失败 | 优先建立契约测试,并统一 `/api/auth`、`/api/article`、`/api/comment` 或 `/api/app/**` 路由规范 |
|
||||
| 生产/公网中间件配置在默认配置中 | 自动化测试可能误连真实服务 | 建立 `application-test.yml`,CI 强制覆盖数据源 |
|
||||
| 自有测试目录缺失 | 回归依赖人工验证 | 先补 Service 单元测试和 Controller 集成测试 |
|
||||
| 需求文档与代码实现差异 | 验收标准不清 | 将未实现需求标为 pending/xfail,并建立需求追踪 |
|
||||
| 前端缺少测试脚本 | UI 回归成本高 | 引入 Vitest 和 Playwright,先覆盖核心旅程 |
|
||||
| 点赞计数并发更新 | 可能出现计数错乱或负数 | 加并发集成测试,必要时改为原子 SQL 更新 |
|
||||
| 评论和文章富文本安全 | XSS 风险 | 加 payload 回归测试,确认过滤链路覆盖请求体 |
|
||||
| `AuthController` | `/api/public` | `POST /user/login`、`POST /admin/login`、`POST /user/register`、`GET /captcha`、`POST /refresh-token` |
|
||||
| `UserController`(用户) | `/api/app/user` | `GET /me`、`GET /{id}`、`POST/DELETE /{id}/follow`、`POST /logout` |
|
||||
| `UserController`(管理) | `/api/admin/user` | `GET /list`、`PUT /{id}/reset-password`、`PUT /{id}/status` |
|
||||
| `PublicArticleController` | `/api/public/article` | `GET /list`、`GET /{id}` |
|
||||
| `ArticleController` | `/api/app/article` | `POST /`、`PUT /{id}`、`GET /mine`、`PUT /{id}/recycle`、`PUT /{id}/restore`、`DELETE /{id}`、`POST /{id}/like` |
|
||||
| `AdminArticleController` | `/api/admin/article` | `GET /list`、`DELETE /{id}` |
|
||||
| `PublicCategoryController` | `/api/public/category` | `GET /list` |
|
||||
| `AdminCategoryController` | `/api/admin/category` | `POST /`、`PUT /{id}`、`DELETE /{id}` |
|
||||
| `PublicTagController` | `/api/public/tag` | `GET /list` |
|
||||
| `AdminTagController` | `/api/admin/tag` | `POST /`(`name` 请求参数) |
|
||||
| `CommentController` | `/api/app/comment` | `POST /`、`DELETE /{id}`、`GET /article/{articleId}`、`POST /{id}/like` |
|
||||
| `AdminCommentController` | `/api/admin/comment` | `GET /list`、`PUT /{id}/audit`、`DELETE /{id}` |
|
||||
|
||||
**前端契约检查**:须将上述列表与 `jog-frontend/src/api/**/*.js` 中的 `url`、`method` 逐一比对。
|
||||
|
||||
---
|
||||
|
||||
## 11. 验收标准
|
||||
## 附录 B:与历史文档的关系
|
||||
|
||||
自动化测试计划落地后,应满足:
|
||||
| 文档 | 角色 |
|
||||
| :--- | :--- |
|
||||
| `jog-system-requirements.md` | 业务验收与 AC 编号来源 |
|
||||
| `jog-system-technical-design.md` | 架构与表结构参考;**路径以附录 A 为准** |
|
||||
| `jog-system-deployment-checklist.md` | 上线人工/半自动巡检清单,可择优改为 E2E |
|
||||
| `jog-system-bug-list.md` | 历史测试执行与缺陷台账;编写回归时注意已关闭项 |
|
||||
|
||||
- 每次合并请求自动运行后端单元测试、集成测试、前端单元/组件测试和构建。
|
||||
- 每次发布前自动运行核心 E2E、数据库迁移验证和安全回归。
|
||||
- 测试报告可追溯到用例、模块、提交和失败截图/日志。
|
||||
- 新增核心业务功能必须同步新增或更新自动化用例。
|
||||
- P0/P1 自动化失败不得合并,不得发布。
|
||||
---
|
||||
|
||||
> **文档结束**
|
||||
> 本文档 V2.0 合并了 `doc` 目录全部文件中与测试相关的信息,并校准当前仓库实现;作为 AI 编写测试用例时的**唯一输入**时,请优先遵循第 13 节用例模板及附录 A 路径,缺失的自动化实现以第 1.2 节「未执行项」为 backlog。
|
||||
|
||||
@@ -0,0 +1,584 @@
|
||||
# Jog System · Cursor 自动化测试输入规范(全量需求展开)
|
||||
|
||||
**文档版本** · **V1.0(Cursor)** · **生成日期** · **2026-05-18**
|
||||
|
||||
| 属性 | 内容 |
|
||||
| :--- | :--- |
|
||||
| **定位** | **AI(含 Cursor Agent)生成自动化测试代码时的唯一输入**;不依赖其他 doc 文件即可推导 JUnit / MockMvc / Testcontainers / Vitest / Playwright / k6 等用例 |
|
||||
| **需求溯源** | 全文展开自 `doc/jog-system-requirements.md`(V1.0,2026-05-17)中的 **FR**、**AC**、**§3 非功能**、**§4 权限矩阵**、**§5 界面**、**§6 测试验收** |
|
||||
| **实现校准** | HTTP 路径与 Controller 以 **附录 A** 为准(当前 `jog-*` 仓库);与需求文字中的示例路径不一致时,**以代码为准**,并在用例 `notes` 中标注「需求差异」 |
|
||||
| **并存说明** | 若与 `jog-system-automated-test-plan.md` 同时存在,**以本 `cursor` 文档为 AI 唯一输入** |
|
||||
|
||||
---
|
||||
|
||||
## 0. 使用方式(写给 AI / Cursor)
|
||||
|
||||
1. **仅依据本文档**生成测试:从「第 6 章」起按 `TC-*` 编号实现;接口路径查附录 A。
|
||||
2. 每条用例必须使用 **第 5 章 JSON 模板** 输出设计(可先批量生成 YAML/JSON fixture,再写代码)。
|
||||
3. `trace` 字段必须含 **FR-* / AC-* / NF-* / PM-* / UI-* / API-*` 之一,便于覆盖率报告。
|
||||
4. 标注 `implementation_gap` 的用例:实现未达需求时,实现 **`@Disabled` + 原因** 或 **`xfail`**,不得静默改预期。
|
||||
5. **优先级**:`P0` 阻塞发布;`P1` 阻塞合并;`P2` 迭代补齐。
|
||||
|
||||
---
|
||||
|
||||
## 1. 仓库与已有验证事实(非需求,仅供上下文)
|
||||
|
||||
| 代号 | 说明 |
|
||||
| :--- | :--- |
|
||||
| T-01 | `mvn -B test` 可通过,但各模块 **`No tests to run`**,无自有 Java 测试 |
|
||||
| T-02 | 前端 `npm run build` 需 **Node ≥ 20**;v16 下 Vite 可能失败 |
|
||||
| T-03 | `jog-frontend/package.json` **无** `test:unit` / `test:e2e` |
|
||||
| N-01 | 契约、Flyway、权限等曾依赖**人工静态对比**(见 `jog-system-bug-list.md`) |
|
||||
|
||||
---
|
||||
|
||||
## 2. 测试环境与数据
|
||||
|
||||
| 项 | 约定 |
|
||||
| :--- | :--- |
|
||||
| JDK | 21 |
|
||||
| Node | ≥ 20(推荐与 CI 锁定一致) |
|
||||
| 集成测试 DB/Redis | Testcontainers 或隔离实例;**禁止**默认连开发者 `application.yml` 中公网地址 |
|
||||
| Profile | `application-test.yml`,Flyway 开启,JWT 密钥固定 |
|
||||
| 数据前缀 | `auto_test_*`,用例结束清理或回滚 |
|
||||
| 角色 | `anonymous` · `user`(`ROLE_USER`)· `admin`(`ROLE_ADMIN`) |
|
||||
|
||||
**已知实现注记(编写断言时参考)**
|
||||
|
||||
- 验证码接口可能为**占位**(见历史 BUG-010):只断言结构存在,不按生产级验证码验收。
|
||||
- 限流 Key 可能缺 IP/用户维度(BUG-014):限流类用例标注 `weak_isolation`。
|
||||
- 需求称访客可读评论;当前评论列表若在 `/api/app/comment/**`,访客可能 **401**:用例中保留 **需求差异** 断言分支。
|
||||
|
||||
---
|
||||
|
||||
## 3. 推荐工具与落地目录
|
||||
|
||||
| 分层 | 工具 | 目录建议 |
|
||||
| :--- | :--- | :--- |
|
||||
| 后端单元 | JUnit 5、Mockito、AssertJ | 各模块 `src/test/java` |
|
||||
| 后端 API/集成 | Spring Boot Test、MockMvc、`@SpringBootTest`、Testcontainers | `jog-admin/src/test/java` 为主 |
|
||||
| 契约 | SpringDoc OpenAPI + 脚本对比 `jog-frontend/src/api` | `scripts/contract/` |
|
||||
| 前端单元 | Vitest、Vue Test Utils、MSW | `jog-frontend` |
|
||||
| E2E | Playwright | `e2e/` 或 `jog-frontend/e2e/` |
|
||||
| 性能/并发 | k6、JMeter | `perf/k6/` |
|
||||
| 可访问性 | axe-core + Playwright | 挂接 E2E |
|
||||
|
||||
---
|
||||
|
||||
## 4. AI 生成单条用例的强制模板
|
||||
|
||||
每条逻辑用例输出为一个对象(可存 `test-fixtures/*.json`):
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "TC-AC-USER-REG-01",
|
||||
"title": "中文短标题",
|
||||
"priority": "P0|P1|P2",
|
||||
"trace": ["AC-USER-REG-01", "FR-USER-REG-01", "API-AUTH-003"],
|
||||
"layers": ["integration", "e2e"],
|
||||
"roles": ["anonymous"],
|
||||
"preconditions": ["干净库或已种子用户", "SMTP mock 或关闭发信断言"],
|
||||
"steps": [
|
||||
{ "action": "HTTP POST ...", "detail": "body 字段..." },
|
||||
{ "action": "查库/查 Redis", "detail": "..." }
|
||||
],
|
||||
"expected": {
|
||||
"http": 200,
|
||||
"bodyJsonPath": { "code": 200, "data.id": "exists" },
|
||||
"db": ["sys_user 新增一行 email=..."],
|
||||
"ui": "可选:E2E 断言"
|
||||
},
|
||||
"negative_cases": [],
|
||||
"implementation_gap": null,
|
||||
"notes": null
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 功能需求全量展开(对应需求 §2)
|
||||
|
||||
以下每节结构:**FR 覆盖表**(规则级必测点)+ **AC 详规**(验收级可执行展开)。
|
||||
**API 映射列**指向附录 A 中最可能用到的路径(若尚无对应能力则写 `N/A` 并标 `implementation_gap`)。
|
||||
|
||||
---
|
||||
|
||||
### 5.1 管理员登录(需求 §2.1)
|
||||
|
||||
#### 5.1.1 FR → 测试要点
|
||||
|
||||
| FR ID | 测试要点 | 建议分层 | 优先级 | API / 备注 |
|
||||
| :--- | :--- | :--- | :--- | :--- |
|
||||
| FR-ADM-LOGIN-01 | 支持手机号+密码、用户名+密码登录 | API / E2E | P0 | `POST /api/public/admin/login` |
|
||||
| FR-ADM-LOGIN-02 | 仅 1 个内置超管,禁止第二管理员 | API / 集成 | P1 | 管理端用户创建接口若不存在则 `implementation_gap` |
|
||||
| FR-ADM-LOGIN-03 | 连续 3 次错密后需验证码;5 次错密锁定 30 分钟 | API | P0 | 结合 `GET /api/public/captcha`(占位时注明) |
|
||||
| FR-ADM-LOGIN-04 | 同 IP 5 分钟内失败 >10 次 → IP 封禁 15 分钟 | API / 集成 | P1 | 限流隔离弱时标 `weak_isolation` |
|
||||
| FR-ADM-LOGIN-05 | 每次成功/失败写登录日志:时间、IP、设备指纹、结果 | 集成 | P0 | 查 `sys_login_log` 或等价表 |
|
||||
| FR-ADM-LOGIN-06 | 会话 8h;记住我 7d | API / 集成 | P1 | JWT `exp`、refresh 配置 |
|
||||
|
||||
#### 5.1.2 AC → 详规
|
||||
|
||||
**TC-AC-ADM-LOGIN-01**(`P0`,`trace`: AC-ADM-LOGIN-01, FR-ADM-LOGIN-01, FR-ADM-LOGIN-05)
|
||||
|
||||
- **目的**:正确用户名或手机号 + 密码登录后台。
|
||||
- **前置**:Flyway 种子管理员存在;已知凭据。
|
||||
- **步骤**:
|
||||
1. `POST /api/public/admin/login`,body:账号字段(与实现一致)、密码、可选 `deviceFingerprint`。
|
||||
2. 断言 200,`data` 含访问令牌与管理员信息。
|
||||
3. 数据库/日志:`result=成功`、IP/时间非空。
|
||||
- **E2E**:登录表单提交后 URL 进入 `/admin` 或仪表盘路由。
|
||||
- **api_ref**:附录 A `AuthController`。
|
||||
|
||||
**TC-AC-ADM-LOGIN-02**(`P0`,`trace`: AC-ADM-LOGIN-02, FR-ADM-LOGIN-03)
|
||||
|
||||
- **目的**:连续 3 次错误密码后第 4 次需验证码。
|
||||
- **步骤**:
|
||||
1. 同一账号连续 3 次错误密码。
|
||||
2. 第 4 次请求:不带验证码 → 期望 400/422 或业务码提示需验证码(与实现一致)。
|
||||
3. `GET /api/public/captcha` 取 `captchaKey`;带正确/错误验证码各测一轮。
|
||||
- **implementation_gap**:若验证码为占位,则断言「接口返回结构」+ 业务仍返回需验证码 **或** 记录产品与实现对齐缺口。
|
||||
|
||||
**TC-AC-ADM-LOGIN-03**(`P0`,`trace`: AC-ADM-LOGIN-03, FR-ADM-LOGIN-03)
|
||||
|
||||
- **目的**:连续 5 次错误锁定 30 分钟。
|
||||
- **步骤**:连续失败至阈值;第 6 次正确密码仍不可登录;期望文案含锁定与约 30 分钟。
|
||||
- **集成**:查用户 `locked_until` 或等价字段。
|
||||
|
||||
**TC-AC-ADM-LOGIN-04**(`P1`,`trace`: AC-ADM-LOGIN-04, FR-ADM-LOGIN-02)
|
||||
|
||||
- **目的**:禁止第二超级管理员。
|
||||
- **步骤**:若存在「创建管理员」API 则调用并期望拒绝;若无 API 则静态断言 `user_type` 枚举与注册路径不通。
|
||||
|
||||
**TC-AC-ADM-LOGIN-05**(`P0`,`trace`: AC-ADM-LOGIN-05, FR-ADM-LOGIN-05)
|
||||
|
||||
- **目的**:登录日志字段完整。
|
||||
- **步骤**:成功一次、失败一次;查日志表:`ip`、`device`/`ua`、`result`、`time`。
|
||||
|
||||
---
|
||||
|
||||
### 5.2 用户注册(需求 §2.2)
|
||||
|
||||
#### 5.2.1 FR → 测试要点
|
||||
|
||||
| FR ID | 测试要点 | 建议分层 | 优先级 |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| FR-USER-REG-01 | 邮箱+密码必填 | API / FE 单元 | P0 |
|
||||
| FR-USER-REG-02 | 密码 8~32 且至少三类字符 | FE + API | P0 |
|
||||
| FR-USER-REG-03 | 邮箱唯一;发验证邮件 | API / 集成 | P0 |
|
||||
| FR-USER-REG-04 | 验证链接 24h | API | P1 |
|
||||
| FR-USER-REG-05 | 同 IP 1h 最多 3 次注册 | API | P0 |
|
||||
| FR-USER-REG-06 | 无自助找回;仅管理员重置 | API | P1 |
|
||||
|
||||
#### 5.2.2 AC → 详规
|
||||
|
||||
**TC-AC-USER-REG-01**(`P0`,`trace`: AC-USER-REG-01, FR-USER-REG-01~03)
|
||||
|
||||
- **步骤**:合法邮箱+合规密码 `POST /api/public/user/register` → 200;DB 用户存在;邮件发送可 mock 断言「调用次数」。
|
||||
|
||||
**TC-AC-USER-REG-02**(`P0`,`trace`: AC-USER-REG-02, FR-USER-REG-03)
|
||||
|
||||
- **步骤**:重复邮箱第二次注册 → 业务错误「该邮箱已被注册」或等价码。
|
||||
|
||||
**TC-AC-USER-REG-03**(`P0`,`trace`: AC-USER-REG-03, FR-USER-REG-02)
|
||||
|
||||
- **步骤**:纯数字或少于 8 位 → 前端校验拦截;绕过前端直调 API → 400/业务错误。
|
||||
|
||||
**TC-AC-USER-REG-04**(`P0`,`trace`: AC-USER-REG-04, FR-USER-REG-05)
|
||||
|
||||
- **步骤**:同 IP(或同测试标识)第 4 次注册 → 限流或「注册过于频繁」。
|
||||
|
||||
**TC-AC-USER-REG-05**(`P0`,`trace`: AC-USER-REG-05, FR-USER-REG-06)
|
||||
|
||||
- **步骤**:管理员 `PUT /api/admin/user/{id}/reset-password`;旧密码失效、新密码可登录。
|
||||
|
||||
#### 5.2.3 FR 补充用例(无独立 AC)
|
||||
|
||||
**TC-FR-USER-REG-04**(`P1`,`trace`: FR-USER-REG-04)
|
||||
|
||||
- **目的**:邮箱验证 token 超过 24h 失效。
|
||||
- **步骤**:插入过期 token 或时钟模拟;请求验证接口期望失败。
|
||||
|
||||
---
|
||||
|
||||
### 5.3 用户登录(需求 §2.3)
|
||||
|
||||
#### 5.3.1 FR → 测试要点
|
||||
|
||||
| FR ID | 测试要点 | 优先级 |
|
||||
| :--- | :--- | :--- |
|
||||
| FR-USER-LOGIN-01 | 邮箱+密码 | P0 |
|
||||
| FR-USER-LOGIN-02 | 最多 3 活跃会话,驱逐最早 | P0 |
|
||||
| FR-USER-LOGIN-03 | OAuth 扩展点可配置开关 | P2 |
|
||||
| FR-USER-LOGIN-04 | 5 次错密锁定 15 分钟 | P0 |
|
||||
| FR-USER-LOGIN-05 | 忘记密码引导联系管理员 | P1 |
|
||||
| FR-USER-LOGIN-06 | 默认 24h;记住我 14d | P0 |
|
||||
|
||||
#### 5.3.2 AC → 详规
|
||||
|
||||
**TC-AC-USER-LOGIN-01**(`P0`,`trace`: AC-USER-LOGIN-01)
|
||||
|
||||
- `POST /api/public/user/login` 成功;返回 Token 与用户信息。
|
||||
|
||||
**TC-AC-USER-LOGIN-02**(`P0`,`trace`: AC-USER-LOGIN-02, FR-USER-LOGIN-02)
|
||||
|
||||
- 模拟 4 个客户端(不同 `deviceFingerprint` 或 User-Agent):第 4 次登录后,最早 Token 调受保护接口 **401/失效**。
|
||||
|
||||
**TC-AC-USER-LOGIN-03**(`P0`,`trace`: AC-USER-LOGIN-03, FR-USER-LOGIN-04)
|
||||
|
||||
- 连续 5 次错误密码 → 锁定 15 分钟;比对管理员锁定时长差异(需求管理员 30min / 用户 15min)。
|
||||
|
||||
**TC-AC-USER-LOGIN-04**(`P1`,`trace`: AC-USER-LOGIN-04, FR-USER-LOGIN-05)
|
||||
|
||||
- E2E:点击忘记密码 → 文案含联系管理员。
|
||||
|
||||
**TC-AC-USER-LOGIN-05**(`P2`,`trace`: AC-USER-LOGIN-05, FR-USER-LOGIN-03)
|
||||
|
||||
- 配置项 OAuth 开关存在;`/api/oauth/**` 或配置表中开关读取断言。
|
||||
|
||||
**TC-AC-USER-LOGIN-06**(`P0`,`trace`: AC-USER-LOGIN-06, FR-USER-LOGIN-06)
|
||||
|
||||
- 记住我勾选:`refresh`/Cookie 过期时间接近 14d;未勾选接近 24h(按实现字段断言)。
|
||||
|
||||
---
|
||||
|
||||
### 5.4 博客发布(需求 §2.4)
|
||||
|
||||
#### 5.4.1 FR → 测试要点
|
||||
|
||||
| FR ID | 测试要点 | 优先级 |
|
||||
| :--- | :--- | :--- |
|
||||
| FR-BLOG-PUB-01 | 未登录不可编辑 | P0 |
|
||||
| FR-BLOG-PUB-02 | 富文本、图片格式与大小、视频嵌入 | P0/P1 |
|
||||
| FR-BLOG-PUB-03 | 手动存草稿 + 30s 自动保存 | P1 |
|
||||
| FR-BLOG-PUB-04 | 可见性:公开/私密/仅粉丝 | P0 |
|
||||
| FR-BLOG-PUB-05 | 已发布可改可见性即时生效 | P0 |
|
||||
| FR-BLOG-PUB-06 | 必选 1 分类、≤5 标签 | P0 |
|
||||
| FR-BLOG-PUB-07 | XSS 过滤;禁止非图上传 | P0 |
|
||||
|
||||
#### 5.4.2 AC → 详规
|
||||
|
||||
**TC-AC-BLOG-PUB-01**(`P0`,`trace`: AC-BLOG-PUB-01, FR-BLOG-PUB-01)
|
||||
|
||||
- E2E:直接打开编辑路由 → 重定向登录;`redirect` 回跳。
|
||||
|
||||
**TC-AC-BLOG-PUB-02**(`P1`,`trace`: AC-BLOG-PUB-02, FR-BLOG-PUB-02)
|
||||
|
||||
- E2E/API:插入图片 URL 或上传接口;超限文件失败。
|
||||
|
||||
**TC-AC-BLOG-PUB-03**(`P1`,`trace`: AC-BLOG-PUB-03, FR-BLOG-PUB-03)
|
||||
|
||||
- 保存草稿后再进入编辑;内容一致;若有自动保存接口则 mock 时钟测 30s(或文档说明降常为 P2)。
|
||||
|
||||
**TC-AC-BLOG-PUB-04**(`P0`,`trace`: AC-BLOG-PUB-04, FR-BLOG-PUB-04)
|
||||
|
||||
- 作者可见私密文;访客 `GET /api/public/article/{id}` → 404 或无权。
|
||||
|
||||
**TC-AC-BLOG-PUB-05**(`P0`,`trace`: AC-BLOG-PUB-05, FR-BLOG-PUB-05)
|
||||
|
||||
- `PUT /api/app/article/{id}` 改可见性后,访客再看详情变化。
|
||||
|
||||
**TC-AC-BLOG-PUB-06**(`P0`,`trace`: AC-BLOG-PUB-06, FR-BLOG-PUB-06)
|
||||
|
||||
- 创建携带 6 个 tagId → 拒绝。
|
||||
|
||||
**TC-AC-BLOG-PUB-07**(`P0`,`trace`: AC-BLOG-PUB-07, FR-BLOG-PUB-07)
|
||||
|
||||
- 上传 `.exe` 或使用文章正文带可执行链接策略;期望拒绝或清洗。
|
||||
|
||||
---
|
||||
|
||||
### 5.5 博客列表查看(需求 §2.5)
|
||||
|
||||
#### 5.5.1 FR → 测试要点
|
||||
|
||||
| FR ID | 测试要点 | 优先级 |
|
||||
| :--- | :--- | :--- |
|
||||
| FR-BLOG-LIST-01 | 按发布时间升降序、浏览量降序 | P0 |
|
||||
| FR-BLOG-LIST-02 | 分类 + 标签组合筛选 | P0 |
|
||||
| FR-BLOG-LIST-03 | 标题+正文全文检索与高亮(UI) | P0 |
|
||||
| FR-BLOG-LIST-04 | 每页 15 条 | P0 |
|
||||
| FR-BLOG-LIST-05 | 私密只对作者列表可见 | P0 |
|
||||
| FR-BLOG-LIST-06 | 仅粉丝可见:作者+粉丝 | P0 |
|
||||
| FR-BLOG-LIST-07 | 浏览量:访客/用户去重;作者本人不看加 | P0 |
|
||||
| FR-BLOG-LIST-08 | 卡片展示阅读量 | P1 |
|
||||
|
||||
#### 5.5.2 AC → 详规
|
||||
|
||||
**TC-AC-BLOG-LIST-01**(`P0`,`trace`: AC-BLOG-LIST-01, FR-BLOG-LIST-01)
|
||||
|
||||
- `GET /api/public/article/list` 带 `sortBy`/`order`(以代码参数名为准);断言顺序。
|
||||
|
||||
**TC-AC-BLOG-LIST-02**(`P0`,`trace`: AC-BLOG-LIST-02, FR-BLOG-LIST-02)
|
||||
|
||||
- 同时 `categoryId` + `tagId`;结果集同时满足。
|
||||
|
||||
**TC-AC-BLOG-LIST-03**(`P0`,`trace`: AC-BLOG-LIST-03, FR-BLOG-LIST-03)
|
||||
|
||||
- 关键字搜索;E2E 断言高亮 DOM 或 API 返回摘要含关键词。
|
||||
|
||||
**TC-AC-BLOG-LIST-04**(`P0`,`trace`: AC-BLOG-LIST-04, FR-BLOG-LIST-04)
|
||||
|
||||
- **说明**:需求原文写「第 3 页」,按 **第 2 页** 语义理解为分页;请求 `page=2`,`pageSize=15`,本页最多 15 条,`total` 与页码一致。
|
||||
|
||||
**TC-AC-BLOG-LIST-05**(`P0`,`trace`: AC-BLOG-LIST-05, FR-BLOG-LIST-05)
|
||||
|
||||
- 作者列表可见私密文;其他用户列表不可见。
|
||||
|
||||
**TC-AC-BLOG-LIST-06**(`P0`,`trace`: AC-BLOG-LIST-06, FR-BLOG-LIST-07)
|
||||
|
||||
- 作者访问本人详情:`view_count` 两次请求不变(或增量为 0)。
|
||||
|
||||
**TC-AC-BLOG-LIST-07**(`P1`,`trace`: AC-BLOG-LIST-07, FR-BLOG-LIST-08)
|
||||
|
||||
- E2E:列表卡片渲染 `viewCount` 字段非空且与接口一致。
|
||||
|
||||
#### 5.5.3 FR 补充
|
||||
|
||||
**TC-FR-BLOG-LIST-06**(`P0`,`trace`: FR-BLOG-LIST-06)
|
||||
|
||||
- 用户 B 关注作者 A 后可见「仅粉丝可见」文;未关注则不可见。
|
||||
|
||||
---
|
||||
|
||||
### 5.6 评论功能(需求 §2.6)
|
||||
|
||||
#### 5.6.1 FR → 测试要点
|
||||
|
||||
| FR ID | 测试要点 | 优先级 |
|
||||
| :--- | :--- | :--- |
|
||||
| FR-COMMENT-01 | 登录才能评;访客可读 | P0 |
|
||||
| FR-COMMENT-02 | 最多 3 级;更深变平级 | P0 |
|
||||
| FR-COMMENT-03 | 审核开关 | P0 |
|
||||
| FR-COMMENT-04 | 作者标识 | P1 |
|
||||
| FR-COMMENT-05 | 管理员删/屏蔽 | P0 |
|
||||
| FR-COMMENT-06 | 回复站内通知 | P1 |
|
||||
| FR-COMMENT-07 | XSS + 敏感词 | P0 |
|
||||
|
||||
#### 5.6.2 AC → 详规
|
||||
|
||||
**TC-AC-COMMENT-01**(`P0`,`trace`: AC-COMMENT-01, FR-COMMENT-01)
|
||||
|
||||
- E2E:未登录点发表 → 登录引导。
|
||||
- **API 差异**:访客调评论列表若需登录,记 `implementation_gap` 相对 FR-COMMENT-01「访客可读」。
|
||||
|
||||
**TC-AC-COMMENT-02**(`P0`,`trace`: AC-COMMENT-02, FR-COMMENT-02)
|
||||
|
||||
- 构造 A→B→C→D 四级;断言 D 与 C 平级 `level`/结构。
|
||||
|
||||
**TC-AC-COMMENT-03**(`P0`,`trace`: AC-COMMENT-03, FR-COMMENT-03)
|
||||
|
||||
- 开关开启:`POST` 评论后前台列表无;管理员 `PUT /api/admin/comment/{id}/audit` 后可见。
|
||||
|
||||
**TC-AC-COMMENT-04**(`P1`,`trace`: AC-COMMENT-04, FR-COMMENT-04)
|
||||
|
||||
- 作者账号评论:`isAuthor` 或 UI 徽章。
|
||||
|
||||
**TC-AC-COMMENT-05**(`P0`,`trace`: AC-COMMENT-05, FR-COMMENT-05)
|
||||
|
||||
- 屏蔽后:其它用户不可见;发表者可见(按实现 status)。
|
||||
|
||||
**TC-AC-COMMENT-06**(`P1`,`trace`: AC-COMMENT-06, FR-COMMENT-06)
|
||||
|
||||
- A 回复 B 后 B 的通知列表/未读数+1(若未实现则 `implementation_gap`)。
|
||||
|
||||
**TC-AC-COMMENT-07**(`P0`,`trace`: AC-COMMENT-07, FR-COMMENT-07)
|
||||
|
||||
- 评论含 `<script>`:持久化字段无执行;详情接口返回转义/清洗后串。
|
||||
|
||||
---
|
||||
|
||||
## 6. 非功能性需求展开(对应需求 §3)
|
||||
|
||||
### 6.1 性能与体验(§3.1)
|
||||
|
||||
| TC ID | 指标来源 | 测试方法 | 通过条件 | 优先级 |
|
||||
| :--- | :--- | :--- | :--- | :--- |
|
||||
| TC-NF-PERF-01 | §3.1.1 首屏 ≤2s | Lighthouse 4G 节流 | LCP/Performance 预算 | P1 |
|
||||
| TC-NF-PERF-02 | §3.1.1 交互 ≤500ms | Playwright `performance.now()` 采样 | 关键点击-反馈延迟 | P2 |
|
||||
| TC-NF-PERF-03 | §3.1.2 列表 P95 ≤500ms | k6 | `p(95)<500ms` | P1 |
|
||||
| TC-NF-PERF-04 | §3.1.2 详情 P95 ≤300ms | k6 | `p(95)<300ms` | P1 |
|
||||
| TC-NF-PERF-05 | §3.1.2 登录注册 P95 ≤800ms | k6 | `p(95)<800ms` | P1 |
|
||||
| TC-NF-PERF-06 | §3.1.3 ≥500 并发在线 | k6 scenario | 错误率 <1%,无 OOM | P2 |
|
||||
| TC-NF-PERF-07 | §3.1.3 列表/详情 TPS ≥100 | k6 | 达标或出报告 | P2 |
|
||||
|
||||
### 6.2 安全性(§3.2)
|
||||
|
||||
| TC ID | 要求 | 测试方法 | 通过条件 | 优先级 |
|
||||
| :--- | :--- | :--- | :--- | :--- |
|
||||
| TC-NF-SEC-01 | bcrypt 存储 | JDBC 读 `password` | 以 `$2a$` 等开头 | P0 |
|
||||
| TC-NF-SEC-02 | 生产 HTTPS | 部署/config 检查 | 301 到 HTTPS | P1 |
|
||||
| TC-NF-SEC-03 | SQL 注入 | 参数化模糊测试 / OWASP | 无异常泄露 | P0 |
|
||||
| TC-NF-SEC-04 | XSS | API 注入脚本 | 无执行,已转义 | P0 |
|
||||
| TC-NF-SEC-05 | CSRF | 需求要求校验 Token | **若项目为纯 JWT 无 Cookie** 则记差异并测 CORS | P1 |
|
||||
| TC-NF-SEC-06 | 敏感接口限流 | 突增登录/注册 | 429 或业务限流 | P0 |
|
||||
| TC-NF-SEC-07 | 日志无密码 Token | 扫日志输出 | 无明文 | P1 |
|
||||
|
||||
### 6.3 兼容性(§3.3)
|
||||
|
||||
| TC ID | 范围 | 测试方法 | 优先级 |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| TC-NF-COMP-01 | Chrome/Edge/Safari 近三版本 | Playwright projects | P1 |
|
||||
| TC-NF-COMP-02 | 视口 320~1920 | Playwright viewport 矩阵 | P1 |
|
||||
| TC-NF-COMP-03 | 触控可点区域 | E2E `boundingBox` ≥44px | P2 |
|
||||
|
||||
### 6.4 可用性与运维(§3.4)
|
||||
|
||||
| TC ID | 要求 | 测试方法 | 优先级 |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| TC-NF-AVAIL-01 | 月 99.5% | 监控外置,本项标 **manual/ops** | P3 |
|
||||
| TC-NF-AVAIL-02 | 日备份 7 天 | 检查备份任务 | P3 |
|
||||
| TC-NF-AVAIL-03 | RTO/RPO | 灾备演练 | P3 |
|
||||
|
||||
---
|
||||
|
||||
## 7. 权限矩阵用例(对应需求 §4.2)
|
||||
|
||||
**通则**:对每个「管理员 / 注册用户 / 访客」×「操作」组合生成 **允许成功** 或 **401/403** 断言。使用附录 A 中真实路径。
|
||||
|
||||
| TC ID | 矩阵坐标(模块·操作) | 角色 | 预期 |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| TC-PM-001 | 管理员登录·登录后台 | Admin | 成功 |
|
||||
| TC-PM-002 | 管理员登录·登录后台 | User/Guest | 拒绝(无此入口或 403) |
|
||||
| TC-PM-003 | 用户注册·提交申请 | Guest | 成功 |
|
||||
| TC-PM-004 | 博客发布·创建 | Guest | 401 |
|
||||
| TC-PM-005 | 博客发布·创建 | User | 成功 |
|
||||
| TC-PM-006 | 博客浏览·公开列表 | Guest | 200 |
|
||||
| TC-PM-007 | 博客浏览·私密文详情 | Guest | 404/403 |
|
||||
| TC-PM-008 | 博客浏览·私密文详情 | Author | 200 |
|
||||
| TC-PM-009 | 评论·发表评论 | Guest | 401 |
|
||||
| TC-PM-010 | 评论·发表评论 | User | 200 |
|
||||
| TC-PM-011 | 评论·删除/屏蔽 | User | 403 |
|
||||
| TC-PM-012 | 评论·删除/屏蔽 | Admin | 200 |
|
||||
| TC-PM-013 | 后台·用户列表 | User | 403 |
|
||||
| TC-PM-014 | 后台·用户列表 | Admin | 200 |
|
||||
| TC-PM-015 | 后台·分类标签 CRUD | Admin | 200 |
|
||||
|
||||
(实现完整矩阵时可脚本化:读取 §4.2 表生成组合。)
|
||||
|
||||
---
|
||||
|
||||
## 8. 界面与交互(对应需求 §5)
|
||||
|
||||
| TC ID | 来源 | 场景 | 建议工具 |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| TC-UI-001 | §5.1 对比度 WCAG AA | 首页/文章页 axe | Playwright+axe |
|
||||
| TC-UI-002 | §5.1 深色模式 | 切换 theme DOM | E2E |
|
||||
| TC-UI-003 | §5.2.1 列表卡片字段 | 标题摘要封面时间阅读量分类 | E2E 快照或字段断言 |
|
||||
| TC-UI-004 | §5.2.1 悬停 scale | hover `transform` | E2E 可选 |
|
||||
| TC-UI-005 | §5.2.1 骨架屏 | loading 状态 | E2E |
|
||||
| TC-UI-006 | §5.2.2 详情排版/灯箱/分享 | 视项目是否存在按钮 | E2E |
|
||||
| TC-UI-007 | §5.2.3 编辑页分栏/自动保存指示/二次确认 | E2E |
|
||||
| TC-UI-008 | §5.2.4 移动端汉堡/单列/工具栏 | viewport 矩阵 |
|
||||
| TC-UI-009 | §5.3 空/加载/错误/超时 UI | 网络 mock |
|
||||
|
||||
---
|
||||
|
||||
## 9. 测试验收与流程(对应需求 §6)
|
||||
|
||||
| 需求原文 | 转化为自动化门禁 |
|
||||
| :--- | :--- |
|
||||
| §6.1 功能全覆盖 | Jacoco/覆盖率报告 + TC 追溯矩阵 100% 可映射 |
|
||||
| §6.2.1 性能 | k6 JOB 接 CI nightly |
|
||||
| §6.2.2 安全 | OWASP ZAP 或依赖 `sqlmap` 仅 stag、密码 bcrypt 单测 |
|
||||
| §6.2.3 兼容 | Playwright 多浏览器 project |
|
||||
| §6.3 P0/P1 清零 | Issue 与 `@Tag("p0")` 对应 |
|
||||
|
||||
---
|
||||
|
||||
## 10. API 回归清单(与需求交叉的快捷索引)
|
||||
|
||||
> 详细路径与 HTTP 方法见附录 A;实现测试时 `{id}` 用例内替换。
|
||||
|
||||
**认证**:`POST /api/public/user/login`,`POST /api/public/admin/login`,`POST /api/public/user/register`,`POST /api/public/refresh-token`,`GET /api/public/captcha`
|
||||
|
||||
**用户**:`GET /api/app/user/me`,`POST/DELETE /api/app/user/{id}/follow`,`GET /api/admin/user/list`,`PUT /api/admin/user/{id}/reset-password`,`PUT /api/admin/user/{id}/status`
|
||||
|
||||
**文章**:`GET /api/public/article/list`,`GET /api/public/article/{id}`,`POST /api/app/article`,`PUT /api/app/article/{id}`,`GET /api/app/article/mine`,`PUT /api/app/article/{id}/recycle`,`PUT /api/app/article/{id}/restore`,`DELETE /api/app/article/{id}`,`POST /api/app/article/{id}/like`,`GET/DELETE /api/admin/article/*`
|
||||
|
||||
**分类/标签**:`GET /api/public/category/list`,`GET /api/public/tag/list`,`POST/PUT/DELETE /api/admin/category/*`,`POST /api/admin/tag`
|
||||
|
||||
**评论**:`POST /api/app/comment`,`DELETE /api/app/comment/{id}`,`GET /api/app/comment/article/{articleId}`,`POST /api/app/comment/{id}/like`,`GET /api/admin/comment/list`,`PUT /api/admin/comment/{id}/audit`,`DELETE /api/admin/comment/{id}`
|
||||
|
||||
---
|
||||
|
||||
## 附录 A:后端路由(当前代码,`jog-*`)
|
||||
|
||||
| Controller / 前缀 | 方法摘要 |
|
||||
| :--- | :--- |
|
||||
| `/api/public`(Auth) | `POST /user/login`,`POST /admin/login`,`POST /user/register`,`GET /captcha`,`POST /refresh-token` |
|
||||
| `/api/app/user` | `GET /me`,`GET /{id}`,`POST/DELETE /{id}/follow`,`POST /logout` |
|
||||
| `/api/admin/user` | `GET /list`,`PUT /{id}/reset-password`,`PUT /{id}/status` |
|
||||
| `/api/public/article` | `GET /list`,`GET /{id}` |
|
||||
| `/api/app/article` | `POST /`,`PUT /{id}`,`GET /mine`,`PUT /{id}/recycle`,`PUT /{id}/restore`,`DELETE /{id}`,`POST /{id}/like` |
|
||||
| `/api/admin/article` | `GET /list`,`DELETE /{id}` |
|
||||
| `/api/public/category` | `GET /list` |
|
||||
| `/api/admin/category` | `POST /`,`PUT /{id}`,`DELETE /{id}` |
|
||||
| `/api/public/tag` | `GET /list` |
|
||||
| `/api/admin/tag` | `POST /`(`name` 参数) |
|
||||
| `/api/app/comment` | `POST /`,`DELETE /{id}`,`GET /article/{articleId}`,`POST /{id}/like` |
|
||||
| `/api/admin/comment` | `GET /list`,`PUT /{id}/audit`,`DELETE /{id}` |
|
||||
|
||||
**契约**:实现后须将上表与 `jog-frontend/src/api/**/*.js` 的 `url`+`method` 对齐。
|
||||
|
||||
---
|
||||
|
||||
## 附录 B:追溯索引(验收 ID → 用例 ID 前缀)
|
||||
|
||||
| 需求 AC | 本文 TC |
|
||||
| :--- | :--- |
|
||||
| AC-ADM-LOGIN-* | TC-AC-ADM-LOGIN-0* |
|
||||
| AC-USER-REG-* | TC-AC-USER-REG-0* |
|
||||
| AC-USER-LOGIN-* | TC-AC-USER-LOGIN-0* |
|
||||
| AC-BLOG-PUB-* | TC-AC-BLOG-PUB-0* |
|
||||
| AC-BLOG-LIST-* | TC-AC-BLOG-LIST-0* |
|
||||
| AC-COMMENT-* | TC-AC-COMMENT-0* |
|
||||
|
||||
---
|
||||
|
||||
## 附录 C:`jog-system-requirements.md` 全部 AC 一行一用例(共 37 条)
|
||||
|
||||
| # | 需求 AC | 本文主用例 ID | 所在章节 |
|
||||
|:-:| :--- | :--- | :--- |
|
||||
| 1 | AC-ADM-LOGIN-01 | TC-AC-ADM-LOGIN-01 | §5.1.2 |
|
||||
| 2 | AC-ADM-LOGIN-02 | TC-AC-ADM-LOGIN-02 | §5.1.2 |
|
||||
| 3 | AC-ADM-LOGIN-03 | TC-AC-ADM-LOGIN-03 | §5.1.2 |
|
||||
| 4 | AC-ADM-LOGIN-04 | TC-AC-ADM-LOGIN-04 | §5.1.2 |
|
||||
| 5 | AC-ADM-LOGIN-05 | TC-AC-ADM-LOGIN-05 | §5.1.2 |
|
||||
| 6 | AC-USER-REG-01 | TC-AC-USER-REG-01 | §5.2.2 |
|
||||
| 7 | AC-USER-REG-02 | TC-AC-USER-REG-02 | §5.2.2 |
|
||||
| 8 | AC-USER-REG-03 | TC-AC-USER-REG-03 | §5.2.2 |
|
||||
| 9 | AC-USER-REG-04 | TC-AC-USER-REG-04 | §5.2.2 |
|
||||
| 10 | AC-USER-REG-05 | TC-AC-USER-REG-05 | §5.2.2 |
|
||||
| 11 | AC-USER-LOGIN-01 | TC-AC-USER-LOGIN-01 | §5.3.2 |
|
||||
| 12 | AC-USER-LOGIN-02 | TC-AC-USER-LOGIN-02 | §5.3.2 |
|
||||
| 13 | AC-USER-LOGIN-03 | TC-AC-USER-LOGIN-03 | §5.3.2 |
|
||||
| 14 | AC-USER-LOGIN-04 | TC-AC-USER-LOGIN-04 | §5.3.2 |
|
||||
| 15 | AC-USER-LOGIN-05 | TC-AC-USER-LOGIN-05 | §5.3.2 |
|
||||
| 16 | AC-USER-LOGIN-06 | TC-AC-USER-LOGIN-06 | §5.3.2 |
|
||||
| 17 | AC-BLOG-PUB-01 | TC-AC-BLOG-PUB-01 | §5.4.2 |
|
||||
| 18 | AC-BLOG-PUB-02 | TC-AC-BLOG-PUB-02 | §5.4.2 |
|
||||
| 19 | AC-BLOG-PUB-03 | TC-AC-BLOG-PUB-03 | §5.4.2 |
|
||||
| 20 | AC-BLOG-PUB-04 | TC-AC-BLOG-PUB-04 | §5.4.2 |
|
||||
| 21 | AC-BLOG-PUB-05 | TC-AC-BLOG-PUB-05 | §5.4.2 |
|
||||
| 22 | AC-BLOG-PUB-06 | TC-AC-BLOG-PUB-06 | §5.4.2 |
|
||||
| 23 | AC-BLOG-PUB-07 | TC-AC-BLOG-PUB-07 | §5.4.2 |
|
||||
| 24 | AC-BLOG-LIST-01 | TC-AC-BLOG-LIST-01 | §5.5.2 |
|
||||
| 25 | AC-BLOG-LIST-02 | TC-AC-BLOG-LIST-02 | §5.5.2 |
|
||||
| 26 | AC-BLOG-LIST-03 | TC-AC-BLOG-LIST-03 | §5.5.2 |
|
||||
| 27 | AC-BLOG-LIST-04 | TC-AC-BLOG-LIST-04 | §5.5.2 |
|
||||
| 28 | AC-BLOG-LIST-05 | TC-AC-BLOG-LIST-05 | §5.5.2 |
|
||||
| 29 | AC-BLOG-LIST-06 | TC-AC-BLOG-LIST-06 | §5.5.2 |
|
||||
| 30 | AC-BLOG-LIST-07 | TC-AC-BLOG-LIST-07 | §5.5.2 |
|
||||
| 31 | AC-COMMENT-01 | TC-AC-COMMENT-01 | §5.6.2 |
|
||||
| 32 | AC-COMMENT-02 | TC-AC-COMMENT-02 | §5.6.2 |
|
||||
| 33 | AC-COMMENT-03 | TC-AC-COMMENT-03 | §5.6.2 |
|
||||
| 34 | AC-COMMENT-04 | TC-AC-COMMENT-04 | §5.6.2 |
|
||||
| 35 | AC-COMMENT-05 | TC-AC-COMMENT-05 | §5.6.2 |
|
||||
| 36 | AC-COMMENT-06 | TC-AC-COMMENT-06 | §5.6.2 |
|
||||
| 37 | AC-COMMENT-07 | TC-AC-COMMENT-07 | §5.6.2 |
|
||||
|
||||
**FR 补充用例**(无单独 AC):`TC-FR-USER-REG-04`(§5.2.3)、`TC-FR-BLOG-LIST-06`(§5.5.3)。
|
||||
|
||||
---
|
||||
|
||||
**文档结束** — 本文档为 **Cursor / AI 生成自动化测试** 的单一输入源;新增需求时只改本文档并升版本号。
|
||||
@@ -0,0 +1,120 @@
|
||||
# Jog System P0/P1 自动化测试自测与问题清单
|
||||
|
||||
文档日期:2026-05-18
|
||||
测试范围:`doc/jog-system-cursor-automated-test-spec.md` 中已选取并落地的 P0/P1 条目(首批)
|
||||
|
||||
## 1. 本次新增自动化测试用例
|
||||
|
||||
### 1.1 认证与注册(`jog-module-system`)
|
||||
|
||||
文件:`jog-module-system/src/test/java/fun/nojava/module/system/service/impl/AuthServiceImplP0P1Test.java`
|
||||
|
||||
- `TC-AC-USER-LOGIN-01`(P0):账号密码正确登录成功并记录成功日志
|
||||
- `TC-AC-USER-LOGIN-03`(P0):密码错误登录失败并记录失败日志
|
||||
- `TC-AC-USER-REG-02`(P0):重复邮箱注册拒绝
|
||||
- `TC-NF-SEC-01`(P0):注册密码编码后入库
|
||||
- `TC-AC-ADM-LOGIN-01`(P1):普通用户调用管理员登录被拒绝
|
||||
- `TC-AC-USER-LOGIN-06`(P0):rememberMe 会话有效期接近 14 天
|
||||
- `TC-AC-USER-LOGIN-03`(P0):已锁定账号拒绝登录
|
||||
- `TC-AC-ADM-LOGIN-02`(P0,Disabled):连续 3 次错密后验证码
|
||||
- `TC-AC-ADM-LOGIN-03`(P0,Disabled):连续 5 次错密锁定
|
||||
|
||||
### 1.2 文章列表与可见性(`jog-module-blog`)
|
||||
|
||||
文件:`jog-module-blog/src/test/java/fun/nojava/module/blog/service/impl/ArticleServiceImplP0P1Test.java`
|
||||
|
||||
- `TC-AC-BLOG-LIST-01`(P0):公开文章列表基本分页流程
|
||||
- `TC-AC-BLOG-PUB-04`(P0):文章不存在时返回 `ARTICLE_NOT_FOUND`
|
||||
- `TC-AC-BLOG-LIST-06`(P0):详情访问会递增浏览量
|
||||
- `TC-AC-BLOG-PUB-04`(P0,Disabled):私密文章访问控制
|
||||
- `TC-AC-BLOG-LIST-06`(P0,Disabled):作者访问不应累计浏览量
|
||||
- `TC-AC-BLOG-PUB-06`(P0,Disabled):标签数量 >5 应拒绝
|
||||
|
||||
## 2. 自测执行与结果
|
||||
|
||||
执行命令:
|
||||
|
||||
`mvn -pl jog-module-system,jog-module-blog test`
|
||||
|
||||
结果汇总:
|
||||
|
||||
- `AuthServiceImplP0P1Test`:`Tests run: 9, Failures: 0, Errors: 0, Skipped: 2`
|
||||
- `ArticleServiceImplP0P1Test`:`Tests run: 6, Failures: 0, Errors: 0, Skipped: 3`
|
||||
- 总体:构建成功;已落地测试全部通过;实现缺口用例以 `@Disabled` 标注并进入问题清单
|
||||
|
||||
## 3. P0/P1 问题修复结果
|
||||
|
||||
> 本节为本轮修复后的状态更新。
|
||||
|
||||
### 已修复(P0)
|
||||
|
||||
1. **连续失败后验证码流程(FR-ADM-LOGIN-03)**
|
||||
- 修复:当失败次数达到阈值后,登录必须携带验证码参数;缺失返回 `CAPTCHA_REQUIRED`,错误返回 `CAPTCHA_INVALID`。
|
||||
|
||||
2. **连续失败后账号锁定(FR-ADM-LOGIN-03 / FR-USER-LOGIN-04)**
|
||||
- 修复:登录失败计数写入用户,达到阈值后按角色锁定(管理员 30 分钟、用户 15 分钟),锁定到期自动解锁。
|
||||
|
||||
3. **私密/仅粉丝文章访问控制(FR-BLOG-PUB-04)**
|
||||
- 修复:详情访问增加可见性校验;私密仅作者可见;仅粉丝可见需存在关注关系。
|
||||
|
||||
4. **作者本人浏览不累计阅读量(FR-BLOG-LIST-07)**
|
||||
- 修复:浏览量递增逻辑增加访问者上下文,作者本人访问不增加 `view_count`。
|
||||
|
||||
5. **标签数量上限未限制为 5(FR-BLOG-PUB-06)**
|
||||
- 修复:创建/编辑文章时统一校验 `tagIds`,超过 5 个返回 `TAG_LIMIT_EXCEEDED`。
|
||||
|
||||
### 已修复(P1)
|
||||
|
||||
6. **密码规则与规范不一致(FR-USER-REG-02)**
|
||||
- 修复:登录/注册密码校验调整为 8~32 位,且至少包含四类中的三类(大写、小写、数字、特殊字符)。
|
||||
|
||||
7. **登录日志字段映射不完整(FR-ADM-LOGIN-05)**
|
||||
- 修复:补齐日志字段映射(`ip_address`、`device_fingerprint`),并按用户类型正确写入 `login_type`。
|
||||
|
||||
### 当前遗留
|
||||
|
||||
- 本轮已跟踪的 7 项问题均已完成修复并由自动化测试覆盖通过。
|
||||
|
||||
## 4. 原始问题记录(归档)
|
||||
|
||||
### P0
|
||||
|
||||
1. **未实现连续失败后验证码流程(FR-ADM-LOGIN-03)**
|
||||
- 现状:登录逻辑未包含“3 次失败后需验证码”的状态机与校验。
|
||||
- 影响:无法满足管理员登录安全基线。
|
||||
- 对应用例:`TC-AC-ADM-LOGIN-02`(Disabled)。
|
||||
|
||||
2. **未实现连续失败后账号锁定(FR-ADM-LOGIN-03 / FR-USER-LOGIN-04)**
|
||||
- 现状:登录失败仅返回错误,未维护失败计数/锁定时长。
|
||||
- 影响:暴力破解风险增加。
|
||||
- 对应用例:`TC-AC-ADM-LOGIN-03`(Disabled)。
|
||||
|
||||
3. **私密/仅粉丝文章访问控制缺失(FR-BLOG-PUB-04)**
|
||||
- 现状:公开详情查询流程未基于 `visibility` 与访问者关系做权限拦截。
|
||||
- 影响:私密内容可能被未授权用户访问。
|
||||
- 对应用例:`TC-AC-BLOG-PUB-04`(Disabled)。
|
||||
|
||||
4. **作者本人浏览仍会累计阅读量(FR-BLOG-LIST-07)**
|
||||
- 现状:浏览量递增逻辑无“作者本人不加”分支。
|
||||
- 影响:阅读量统计失真。
|
||||
- 对应用例:`TC-AC-BLOG-LIST-06`(Disabled)。
|
||||
|
||||
5. **标签数量上限未限制为 5(FR-BLOG-PUB-06)**
|
||||
- 现状:创建/编辑文章时未校验 `tagIds` 上限。
|
||||
- 影响:与发布规则不一致,可能导致前后端行为分裂。
|
||||
- 对应用例:`TC-AC-BLOG-PUB-06`(Disabled)。
|
||||
|
||||
### P1
|
||||
|
||||
6. **密码规则与规范不一致(FR-USER-REG-02)**
|
||||
- 现状:注册/登录 DTO 使用 6~20 位校验,且未体现“三类字符”强度规则。
|
||||
- 影响:低于测试规范要求(规范要求 8~32 且至少三类字符)。
|
||||
|
||||
7. **登录日志字段映射不完整(FR-ADM-LOGIN-05)**
|
||||
- 现状:登录日志实体中的 `userAgent` 未持久化映射,且登录类型写死为用户类型。
|
||||
- 影响:审计维度不完整,管理员/用户登录区分不准确。
|
||||
|
||||
## 5. 数据清理说明
|
||||
|
||||
- 本次新增测试均为 Mockito 单元测试,不直接连接数据库,不会新增真实库数据。
|
||||
- 后续若补充集成测试(落库场景),将统一采用“事务回滚 + 必要时显式删除”策略,确保每条用例结束后清理测试数据。
|
||||
@@ -22,5 +22,10 @@
|
||||
<artifactId>lombok</artifactId>
|
||||
<optional>true</optional>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</project>
|
||||
|
||||
+1
-1
@@ -35,7 +35,7 @@ public class PublicArticleController {
|
||||
public ApiResult<ArticleVO> getArticle(
|
||||
@PathVariable Long id,
|
||||
@AuthenticationPrincipal Long userId) {
|
||||
articleService.incrementViewCount(id);
|
||||
articleService.incrementViewCount(id, userId);
|
||||
return ApiResult.success(articleService.getArticleDetail(id, userId));
|
||||
}
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ public interface ArticleService {
|
||||
|
||||
void restoreArticle(Long userId, Long articleId);
|
||||
|
||||
void incrementViewCount(Long articleId);
|
||||
void incrementViewCount(Long articleId, Long viewerUserId);
|
||||
|
||||
boolean toggleLike(Long userId, Long articleId);
|
||||
|
||||
|
||||
+59
-6
@@ -12,6 +12,8 @@ import fun.nojava.module.blog.entity.*;
|
||||
import fun.nojava.module.blog.mapper.*;
|
||||
import fun.nojava.module.blog.service.ArticleService;
|
||||
import fun.nojava.module.system.entity.User;
|
||||
import fun.nojava.module.system.entity.UserFollow;
|
||||
import fun.nojava.module.system.mapper.UserFollowMapper;
|
||||
import fun.nojava.module.system.mapper.UserMapper;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
@@ -25,16 +27,21 @@ import java.util.List;
|
||||
@RequiredArgsConstructor
|
||||
public class ArticleServiceImpl implements ArticleService {
|
||||
|
||||
private static final int MAX_TAG_COUNT = 5;
|
||||
|
||||
private final ArticleMapper articleMapper;
|
||||
private final CategoryMapper categoryMapper;
|
||||
private final TagMapper tagMapper;
|
||||
private final ArticleTagMapper articleTagMapper;
|
||||
private final ArticleLikeMapper articleLikeMapper;
|
||||
private final UserMapper userMapper;
|
||||
private final UserFollowMapper userFollowMapper;
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public Long createArticle(Long userId, CreateArticleDTO dto) {
|
||||
validateTagCount(dto.getTagIds());
|
||||
|
||||
Article article = new Article();
|
||||
article.setUserId(userId);
|
||||
article.setCategoryId(dto.getCategoryId());
|
||||
@@ -71,6 +78,7 @@ public class ArticleServiceImpl implements ArticleService {
|
||||
@Transactional
|
||||
public void updateArticle(Long userId, Long articleId, UpdateArticleDTO dto) {
|
||||
Article article = getArticleAndCheckOwner(userId, articleId);
|
||||
validateTagCount(dto.getTagIds());
|
||||
|
||||
article.setTitle(dto.getTitle());
|
||||
article.setSummary(dto.getSummary());
|
||||
@@ -106,6 +114,7 @@ public class ArticleServiceImpl implements ArticleService {
|
||||
if (article == null) {
|
||||
throw new BusinessException(ErrorCode.ARTICLE_NOT_FOUND);
|
||||
}
|
||||
ensureReadable(article, currentUserId);
|
||||
|
||||
return buildArticleVO(article, currentUserId);
|
||||
}
|
||||
@@ -191,8 +200,7 @@ public class ArticleServiceImpl implements ArticleService {
|
||||
@Override
|
||||
@Transactional
|
||||
public void adminDeleteArticle(Long articleId) {
|
||||
Article article = articleMapper.selectById(articleId);
|
||||
if (article == null) {
|
||||
if (articleMapper.selectById(articleId) == null) {
|
||||
throw new BusinessException(ErrorCode.ARTICLE_NOT_FOUND);
|
||||
}
|
||||
articleMapper.deleteById(articleId);
|
||||
@@ -217,12 +225,16 @@ public class ArticleServiceImpl implements ArticleService {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void incrementViewCount(Long articleId) {
|
||||
public void incrementViewCount(Long articleId, Long viewerUserId) {
|
||||
Article article = articleMapper.selectById(articleId);
|
||||
if (article != null) {
|
||||
article.setViewCount(article.getViewCount() + 1);
|
||||
articleMapper.updateById(article);
|
||||
if (article == null) {
|
||||
return;
|
||||
}
|
||||
if (viewerUserId != null && viewerUserId.equals(article.getUserId())) {
|
||||
return;
|
||||
}
|
||||
article.setViewCount(article.getViewCount() + 1);
|
||||
articleMapper.updateById(article);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -276,6 +288,47 @@ public class ArticleServiceImpl implements ArticleService {
|
||||
return article;
|
||||
}
|
||||
|
||||
private void validateTagCount(List<Long> tagIds) {
|
||||
if (tagIds != null && tagIds.size() > MAX_TAG_COUNT) {
|
||||
throw new BusinessException(ErrorCode.TAG_LIMIT_EXCEEDED);
|
||||
}
|
||||
}
|
||||
|
||||
private void ensureReadable(Article article, Long currentUserId) {
|
||||
if (!Integer.valueOf(ArticleStatus.PUBLISHED.getCode()).equals(article.getStatus())) {
|
||||
if (currentUserId == null || !currentUserId.equals(article.getUserId())) {
|
||||
throw new BusinessException(ErrorCode.PERMISSION_DENIED);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (Integer.valueOf(ArticleVisibility.PUBLIC.getCode()).equals(article.getVisibility())) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentUserId != null && currentUserId.equals(article.getUserId())) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (Integer.valueOf(ArticleVisibility.PRIVATE.getCode()).equals(article.getVisibility())) {
|
||||
throw new BusinessException(ErrorCode.PERMISSION_DENIED);
|
||||
}
|
||||
|
||||
if (Integer.valueOf(ArticleVisibility.FOLLOWERS_ONLY.getCode()).equals(article.getVisibility())) {
|
||||
if (currentUserId == null) {
|
||||
throw new BusinessException(ErrorCode.PERMISSION_DENIED);
|
||||
}
|
||||
boolean following = userFollowMapper.selectCount(
|
||||
new LambdaQueryWrapper<UserFollow>()
|
||||
.eq(UserFollow::getFollowerId, currentUserId)
|
||||
.eq(UserFollow::getFolloweeId, article.getUserId())
|
||||
) > 0;
|
||||
if (!following) {
|
||||
throw new BusinessException(ErrorCode.PERMISSION_DENIED);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private ArticleVO buildArticleVO(Article article, Long currentUserId) {
|
||||
User author = userMapper.selectById(article.getUserId());
|
||||
Category category = article.getCategoryId() != null ? categoryMapper.selectById(article.getCategoryId()) : null;
|
||||
|
||||
+187
@@ -0,0 +1,187 @@
|
||||
package fun.nojava.module.blog.service.impl;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.Wrapper;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import fun.nojava.common.enums.ArticleStatus;
|
||||
import fun.nojava.common.enums.ArticleVisibility;
|
||||
import fun.nojava.common.exception.BusinessException;
|
||||
import fun.nojava.common.exception.ErrorCode;
|
||||
import fun.nojava.common.model.PageResult;
|
||||
import fun.nojava.module.blog.dto.ArticleListVO;
|
||||
import fun.nojava.module.blog.dto.CreateArticleDTO;
|
||||
import fun.nojava.module.blog.entity.Article;
|
||||
import fun.nojava.module.blog.mapper.*;
|
||||
import fun.nojava.module.system.mapper.UserFollowMapper;
|
||||
import fun.nojava.module.system.mapper.UserMapper;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Tag;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class ArticleServiceImplP0P1Test {
|
||||
|
||||
@Mock
|
||||
private ArticleMapper articleMapper;
|
||||
@Mock
|
||||
private CategoryMapper categoryMapper;
|
||||
@Mock
|
||||
private TagMapper tagMapper;
|
||||
@Mock
|
||||
private ArticleTagMapper articleTagMapper;
|
||||
@Mock
|
||||
private ArticleLikeMapper articleLikeMapper;
|
||||
@Mock
|
||||
private UserMapper userMapper;
|
||||
@Mock
|
||||
private UserFollowMapper userFollowMapper;
|
||||
|
||||
@InjectMocks
|
||||
private ArticleServiceImpl articleService;
|
||||
|
||||
@Test
|
||||
@Tag("p0")
|
||||
@DisplayName("TC-AC-BLOG-LIST-01: 公开列表查询条件应限制已发布+公开可见")
|
||||
void shouldConstrainPublicListToPublishedAndPublicArticles() {
|
||||
Page<Article> mockPage = new Page<>(1, 10);
|
||||
mockPage.setRecords(List.of());
|
||||
mockPage.setTotal(0);
|
||||
when(articleMapper.selectPage(org.mockito.ArgumentMatchers.<Page<Article>>any(),
|
||||
org.mockito.ArgumentMatchers.<Wrapper<Article>>any()))
|
||||
.thenReturn(mockPage);
|
||||
|
||||
PageResult<ArticleListVO> result = articleService.listPublicArticles(1, 10, null, null, null);
|
||||
|
||||
assertNotNull(result);
|
||||
verify(articleMapper).selectPage(org.mockito.ArgumentMatchers.<Page<Article>>any(),
|
||||
org.mockito.ArgumentMatchers.<Wrapper<Article>>any());
|
||||
assertEquals(0L, result.getTotal());
|
||||
assertEquals(1, result.getPage());
|
||||
assertEquals(10, result.getPageSize());
|
||||
}
|
||||
|
||||
@Test
|
||||
@Tag("p0")
|
||||
@DisplayName("TC-AC-BLOG-PUB-04: 查询不存在文章应返回 ARTICLE_NOT_FOUND")
|
||||
void shouldThrowWhenArticleDoesNotExist() {
|
||||
when(articleMapper.selectById(999L)).thenReturn(null);
|
||||
|
||||
BusinessException ex = assertThrows(BusinessException.class, () ->
|
||||
articleService.getArticleDetail(999L, null));
|
||||
|
||||
assertEquals(ErrorCode.ARTICLE_NOT_FOUND.getCode(), ex.getCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
@Tag("p0")
|
||||
@DisplayName("TC-AC-BLOG-LIST-06: 访问文章详情应递增浏览量")
|
||||
void shouldIncreaseViewCountWhenArticleExists() {
|
||||
Article article = new Article();
|
||||
article.setId(1L);
|
||||
article.setUserId(2L);
|
||||
article.setViewCount(12);
|
||||
|
||||
when(articleMapper.selectById(1L)).thenReturn(article);
|
||||
|
||||
articleService.incrementViewCount(1L, 3L);
|
||||
|
||||
assertEquals(13, article.getViewCount());
|
||||
verify(articleMapper).updateById(article);
|
||||
}
|
||||
|
||||
@Test
|
||||
@Tag("p0")
|
||||
@DisplayName("TC-AC-BLOG-PUB-04: 未授权用户访问私密文章应被拒绝")
|
||||
void shouldRejectUnauthorizedUserForPrivateArticleDetail() {
|
||||
Article article = new Article();
|
||||
article.setId(2L);
|
||||
article.setUserId(11L);
|
||||
article.setStatus(ArticleStatus.PUBLISHED.getCode());
|
||||
article.setVisibility(ArticleVisibility.PRIVATE.getCode());
|
||||
|
||||
when(articleMapper.selectById(2L)).thenReturn(article);
|
||||
|
||||
BusinessException ex = assertThrows(BusinessException.class, () ->
|
||||
articleService.getArticleDetail(2L, 99L));
|
||||
assertEquals(ErrorCode.PERMISSION_DENIED.getCode(), ex.getCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
@Tag("p0")
|
||||
@DisplayName("TC-AC-BLOG-LIST-06: 作者本人访问不应增加浏览量")
|
||||
void shouldNotIncreaseViewCountWhenAuthorViewsOwnArticle() {
|
||||
Article article = new Article();
|
||||
article.setId(3L);
|
||||
article.setUserId(21L);
|
||||
article.setViewCount(33);
|
||||
|
||||
when(articleMapper.selectById(3L)).thenReturn(article);
|
||||
|
||||
articleService.incrementViewCount(3L, 21L);
|
||||
|
||||
assertEquals(33, article.getViewCount());
|
||||
verify(articleMapper, never()).updateById(any(Article.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@Tag("p0")
|
||||
@DisplayName("TC-AC-BLOG-PUB-06: 超过5个标签应被拒绝")
|
||||
void shouldRejectWhenTagCountExceedsFive() {
|
||||
CreateArticleDTO dto = new CreateArticleDTO();
|
||||
dto.setTitle("tag-limit");
|
||||
dto.setContent("content");
|
||||
dto.setTagIds(List.of(1L, 2L, 3L, 4L, 5L, 6L));
|
||||
|
||||
BusinessException ex = assertThrows(BusinessException.class, () ->
|
||||
articleService.createArticle(1L, dto));
|
||||
assertEquals(ErrorCode.TAG_LIMIT_EXCEEDED.getCode(), ex.getCode());
|
||||
verify(articleMapper, never()).insert(any(Article.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@Tag("p0")
|
||||
@DisplayName("TC-FR-BLOG-LIST-06: 仅粉丝可见文章需关注关系")
|
||||
void shouldRejectFollowerOnlyArticleWhenNotFollowing() {
|
||||
Article article = new Article();
|
||||
article.setId(4L);
|
||||
article.setUserId(88L);
|
||||
article.setStatus(ArticleStatus.PUBLISHED.getCode());
|
||||
article.setVisibility(ArticleVisibility.FOLLOWERS_ONLY.getCode());
|
||||
|
||||
when(articleMapper.selectById(4L)).thenReturn(article);
|
||||
when(userFollowMapper.selectCount(any())).thenReturn(0L);
|
||||
|
||||
BusinessException ex = assertThrows(BusinessException.class, () ->
|
||||
articleService.getArticleDetail(4L, 99L));
|
||||
assertEquals(ErrorCode.PERMISSION_DENIED.getCode(), ex.getCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
@Tag("p0")
|
||||
@DisplayName("TC-FR-BLOG-LIST-06: 已关注用户可访问仅粉丝可见文章")
|
||||
void shouldAllowFollowerOnlyArticleWhenFollowing() {
|
||||
Article article = new Article();
|
||||
article.setId(5L);
|
||||
article.setUserId(89L);
|
||||
article.setStatus(ArticleStatus.PUBLISHED.getCode());
|
||||
article.setVisibility(ArticleVisibility.FOLLOWERS_ONLY.getCode());
|
||||
article.setCategoryId(null);
|
||||
article.setViewCount(0);
|
||||
article.setLikeCount(0);
|
||||
article.setCommentCount(0);
|
||||
|
||||
when(articleMapper.selectById(5L)).thenReturn(article);
|
||||
when(userFollowMapper.selectCount(any())).thenReturn(1L);
|
||||
when(articleTagMapper.selectList(any())).thenReturn(List.of());
|
||||
|
||||
assertDoesNotThrow(() -> articleService.getArticleDetail(5L, 100L));
|
||||
}
|
||||
}
|
||||
@@ -26,5 +26,10 @@
|
||||
<artifactId>lombok</artifactId>
|
||||
<optional>true</optional>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</project>
|
||||
|
||||
+3
-1
@@ -22,6 +22,8 @@ import java.util.UUID;
|
||||
@RequiredArgsConstructor
|
||||
public class AuthController {
|
||||
|
||||
private static final String PLACEHOLDER_CAPTCHA_CODE = "1234";
|
||||
|
||||
private final AuthService authService;
|
||||
|
||||
@Operation(summary = "用户登录")
|
||||
@@ -53,7 +55,7 @@ public class AuthController {
|
||||
public ApiResult<Map<String, String>> captcha() {
|
||||
return ApiResult.success(Map.of(
|
||||
"captchaKey", UUID.randomUUID().toString(),
|
||||
"captchaImage", ""
|
||||
"captchaImage", PLACEHOLDER_CAPTCHA_CODE
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package fun.nojava.module.system.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import lombok.Data;
|
||||
|
||||
@@ -11,8 +12,18 @@ public class LoginDTO {
|
||||
private String email;
|
||||
|
||||
@NotBlank(message = "密码不能为空")
|
||||
@Size(min = 6, max = 20, message = "密码长度6-20位")
|
||||
@Size(min = 8, max = 32, message = "密码长度8-32位")
|
||||
@Pattern(
|
||||
regexp = "^(?:(?=.*[a-z])(?=.*[A-Z])(?=.*\\d)|(?=.*[a-z])(?=.*[A-Z])(?=.*[^A-Za-z0-9])|(?=.*[a-z])(?=.*\\d)(?=.*[^A-Za-z0-9])|(?=.*[A-Z])(?=.*\\d)(?=.*[^A-Za-z0-9])).{8,32}$",
|
||||
message = "密码需包含大小写字母、数字、特殊字符中的至少三类"
|
||||
)
|
||||
private String password;
|
||||
|
||||
private String captchaKey;
|
||||
|
||||
private String captchaCode;
|
||||
|
||||
private String deviceFingerprint;
|
||||
|
||||
private Boolean rememberMe = false;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package fun.nojava.module.system.dto;
|
||||
|
||||
import jakarta.validation.constraints.Email;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import lombok.Data;
|
||||
|
||||
@@ -13,7 +14,11 @@ public class RegisterDTO {
|
||||
private String email;
|
||||
|
||||
@NotBlank(message = "密码不能为空")
|
||||
@Size(min = 6, max = 20, message = "密码长度6-20位")
|
||||
@Size(min = 8, max = 32, message = "密码长度8-32位")
|
||||
@Pattern(
|
||||
regexp = "^(?:(?=.*[a-z])(?=.*[A-Z])(?=.*\\d)|(?=.*[a-z])(?=.*[A-Z])(?=.*[^A-Za-z0-9])|(?=.*[a-z])(?=.*\\d)(?=.*[^A-Za-z0-9])|(?=.*[A-Z])(?=.*\\d)(?=.*[^A-Za-z0-9])).{8,32}$",
|
||||
message = "密码需包含大小写字母、数字、特殊字符中的至少三类"
|
||||
)
|
||||
private String password;
|
||||
|
||||
@NotBlank(message = "昵称不能为空")
|
||||
|
||||
@@ -19,10 +19,11 @@ public class LoginLog {
|
||||
|
||||
private Integer loginType;
|
||||
|
||||
@TableField("ip_address")
|
||||
private String ipAddress;
|
||||
|
||||
@TableField(exist = false)
|
||||
private String userAgent;
|
||||
@TableField("device_fingerprint")
|
||||
private String deviceFingerprint;
|
||||
|
||||
@TableField("result")
|
||||
private Integer status;
|
||||
|
||||
@@ -31,6 +31,10 @@ public class User {
|
||||
|
||||
private Integer status;
|
||||
|
||||
private Integer loginFailCount;
|
||||
|
||||
private LocalDateTime lockedUntil;
|
||||
|
||||
@TableField(fill = FieldFill.INSERT)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
|
||||
+136
-28
@@ -1,6 +1,7 @@
|
||||
package fun.nojava.module.system.service.impl;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import fun.nojava.common.constant.SecurityConstants;
|
||||
import fun.nojava.common.enums.UserStatus;
|
||||
import fun.nojava.common.enums.UserType;
|
||||
import fun.nojava.common.exception.BusinessException;
|
||||
@@ -21,6 +22,7 @@ import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@@ -28,6 +30,8 @@ import java.time.LocalDateTime;
|
||||
@RequiredArgsConstructor
|
||||
public class AuthServiceImpl implements AuthService {
|
||||
|
||||
private static final String PLACEHOLDER_CAPTCHA_CODE = "1234";
|
||||
|
||||
private final UserMapper userMapper;
|
||||
private final LoginLogMapper loginLogMapper;
|
||||
private final UserSessionMapper userSessionMapper;
|
||||
@@ -38,6 +42,54 @@ public class AuthServiceImpl implements AuthService {
|
||||
@Override
|
||||
@Transactional
|
||||
public LoginVO login(LoginDTO dto, String ipAddress, String userAgent) {
|
||||
String loginAccount = dto.getEmail();
|
||||
User user = userMapper.selectOne(
|
||||
new LambdaQueryWrapper<User>()
|
||||
.eq(User::getEmail, loginAccount)
|
||||
.or()
|
||||
.eq(User::getUsername, loginAccount)
|
||||
.or()
|
||||
.eq(User::getPhone, loginAccount)
|
||||
);
|
||||
|
||||
if (user == null) {
|
||||
recordLoginLog(null, loginAccount, ipAddress, resolveDeviceFingerprint(dto, userAgent), 0, 0, "用户不存在");
|
||||
throw new BusinessException(ErrorCode.USER_NOT_FOUND);
|
||||
}
|
||||
|
||||
unlockExpiredLockIfNeeded(user);
|
||||
|
||||
if (Boolean.TRUE.equals(isLocked(user))) {
|
||||
recordLoginLog(user.getId(), loginAccount, ipAddress, resolveDeviceFingerprint(dto, userAgent), 0, user.getType(), "账号已锁定");
|
||||
throw new BusinessException(ErrorCode.ACCOUNT_LOCKED);
|
||||
}
|
||||
|
||||
if (requiresCaptcha(user)) {
|
||||
if (!hasCaptchaPayload(dto)) {
|
||||
recordLoginLog(user.getId(), loginAccount, ipAddress, resolveDeviceFingerprint(dto, userAgent), 0, user.getType(), "需要验证码");
|
||||
throw new BusinessException(ErrorCode.CAPTCHA_REQUIRED);
|
||||
}
|
||||
if (!isCaptchaValid(dto)) {
|
||||
recordLoginLog(user.getId(), loginAccount, ipAddress, resolveDeviceFingerprint(dto, userAgent), 0, user.getType(), "验证码无效");
|
||||
throw new BusinessException(ErrorCode.CAPTCHA_INVALID);
|
||||
}
|
||||
}
|
||||
|
||||
if (!passwordEncoder.matches(dto.getPassword(), user.getPassword())) {
|
||||
onLoginPasswordFailed(user);
|
||||
recordLoginLog(user.getId(), loginAccount, ipAddress, resolveDeviceFingerprint(dto, userAgent), 0, user.getType(), "密码错误");
|
||||
throw new BusinessException(ErrorCode.PASSWORD_ERROR);
|
||||
}
|
||||
|
||||
resetLoginFailState(user);
|
||||
recordLoginLog(user.getId(), loginAccount, ipAddress, resolveDeviceFingerprint(dto, userAgent), 1, user.getType(), null);
|
||||
|
||||
return generateLoginVO(user, dto.getRememberMe());
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public LoginVO adminLogin(LoginDTO dto, String ipAddress, String userAgent) {
|
||||
User user = userMapper.selectOne(
|
||||
new LambdaQueryWrapper<User>()
|
||||
.eq(User::getEmail, dto.getEmail())
|
||||
@@ -46,38 +98,16 @@ public class AuthServiceImpl implements AuthService {
|
||||
.or()
|
||||
.eq(User::getPhone, dto.getEmail())
|
||||
);
|
||||
|
||||
if (user == null) {
|
||||
recordLoginLog(null, dto.getEmail(), ipAddress, userAgent, 0, "用户不存在");
|
||||
recordLoginLog(null, dto.getEmail(), ipAddress, resolveDeviceFingerprint(dto, userAgent), 0, UserType.ADMIN.getCode(), "用户不存在");
|
||||
throw new BusinessException(ErrorCode.USER_NOT_FOUND);
|
||||
}
|
||||
|
||||
if (user.getStatus() == UserStatus.LOCKED.getCode()) {
|
||||
recordLoginLog(user.getId(), dto.getEmail(), ipAddress, userAgent, 0, "账号已锁定");
|
||||
throw new BusinessException(ErrorCode.ACCOUNT_LOCKED);
|
||||
}
|
||||
|
||||
if (!passwordEncoder.matches(dto.getPassword(), user.getPassword())) {
|
||||
recordLoginLog(user.getId(), dto.getEmail(), ipAddress, userAgent, 0, "密码错误");
|
||||
throw new BusinessException(ErrorCode.PASSWORD_ERROR);
|
||||
}
|
||||
|
||||
recordLoginLog(user.getId(), dto.getEmail(), ipAddress, userAgent, 1, null);
|
||||
|
||||
return generateLoginVO(user, dto.getRememberMe());
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public LoginVO adminLogin(LoginDTO dto, String ipAddress, String userAgent) {
|
||||
LoginVO loginVO = login(dto, ipAddress, userAgent);
|
||||
|
||||
User user = userMapper.selectById(loginVO.getUserId());
|
||||
if (user.getType() != UserType.ADMIN.getCode()) {
|
||||
recordLoginLog(user.getId(), dto.getEmail(), ipAddress, resolveDeviceFingerprint(dto, userAgent), 0, UserType.ADMIN.getCode(), "权限不足");
|
||||
throw new BusinessException(ErrorCode.PERMISSION_DENIED);
|
||||
}
|
||||
|
||||
return loginVO;
|
||||
return login(dto, ipAddress, userAgent);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -162,15 +192,93 @@ public class AuthServiceImpl implements AuthService {
|
||||
.build();
|
||||
}
|
||||
|
||||
private void recordLoginLog(Long userId, String email, String ip, String ua, int status, String reason) {
|
||||
private void recordLoginLog(Long userId, String email, String ip, String deviceFingerprint, int status, int loginType, String reason) {
|
||||
LoginLog log = new LoginLog();
|
||||
log.setUserId(userId);
|
||||
log.setEmail(email);
|
||||
log.setLoginType(1);
|
||||
log.setLoginType(loginType);
|
||||
log.setIpAddress(ip);
|
||||
log.setUserAgent(ua);
|
||||
log.setDeviceFingerprint(deviceFingerprint);
|
||||
log.setStatus(status);
|
||||
log.setFailReason(reason);
|
||||
loginLogMapper.insert(log);
|
||||
}
|
||||
|
||||
private String resolveDeviceFingerprint(LoginDTO dto, String userAgent) {
|
||||
if (StringUtils.hasText(dto.getDeviceFingerprint())) {
|
||||
return dto.getDeviceFingerprint();
|
||||
}
|
||||
return userAgent;
|
||||
}
|
||||
|
||||
private boolean requiresCaptcha(User user) {
|
||||
Integer failCount = user.getLoginFailCount();
|
||||
return failCount != null && failCount >= SecurityConstants.CAPTCHA_TRIGGER_COUNT;
|
||||
}
|
||||
|
||||
private boolean hasCaptchaPayload(LoginDTO dto) {
|
||||
return StringUtils.hasText(dto.getCaptchaKey()) && StringUtils.hasText(dto.getCaptchaCode());
|
||||
}
|
||||
|
||||
private boolean isCaptchaValid(LoginDTO dto) {
|
||||
return PLACEHOLDER_CAPTCHA_CODE.equalsIgnoreCase(dto.getCaptchaCode());
|
||||
}
|
||||
|
||||
private void onLoginPasswordFailed(User user) {
|
||||
int failCount = user.getLoginFailCount() == null ? 0 : user.getLoginFailCount();
|
||||
failCount += 1;
|
||||
user.setLoginFailCount(failCount);
|
||||
|
||||
int lockThreshold = SecurityConstants.MAX_LOGIN_FAIL_USER;
|
||||
int lockMinutes = SecurityConstants.USER_LOCK_MINUTES;
|
||||
if (Integer.valueOf(UserType.ADMIN.getCode()).equals(user.getType())) {
|
||||
lockThreshold = SecurityConstants.MAX_LOGIN_FAIL_ADMIN;
|
||||
lockMinutes = SecurityConstants.ADMIN_LOCK_MINUTES;
|
||||
}
|
||||
|
||||
if (failCount >= lockThreshold) {
|
||||
user.setStatus(UserStatus.LOCKED.getCode());
|
||||
user.setLockedUntil(LocalDateTime.now().plusMinutes(lockMinutes));
|
||||
}
|
||||
userMapper.updateById(user);
|
||||
}
|
||||
|
||||
private void resetLoginFailState(User user) {
|
||||
boolean changed = false;
|
||||
if (user.getLoginFailCount() != null && user.getLoginFailCount() > 0) {
|
||||
user.setLoginFailCount(0);
|
||||
changed = true;
|
||||
}
|
||||
if (user.getStatus() != null && user.getStatus() == UserStatus.LOCKED.getCode()) {
|
||||
user.setStatus(UserStatus.NORMAL.getCode());
|
||||
changed = true;
|
||||
}
|
||||
if (user.getLockedUntil() != null) {
|
||||
user.setLockedUntil(null);
|
||||
changed = true;
|
||||
}
|
||||
if (changed) {
|
||||
userMapper.updateById(user);
|
||||
}
|
||||
}
|
||||
|
||||
private void unlockExpiredLockIfNeeded(User user) {
|
||||
if (user.getStatus() == null || user.getStatus() != UserStatus.LOCKED.getCode()) {
|
||||
return;
|
||||
}
|
||||
if (user.getLockedUntil() == null || user.getLockedUntil().isAfter(LocalDateTime.now())) {
|
||||
return;
|
||||
}
|
||||
user.setStatus(UserStatus.NORMAL.getCode());
|
||||
user.setLockedUntil(null);
|
||||
user.setLoginFailCount(0);
|
||||
userMapper.updateById(user);
|
||||
}
|
||||
|
||||
private Boolean isLocked(User user) {
|
||||
if (user.getStatus() == null || user.getStatus() != UserStatus.LOCKED.getCode()) {
|
||||
return false;
|
||||
}
|
||||
return user.getLockedUntil() == null || user.getLockedUntil().isAfter(LocalDateTime.now());
|
||||
}
|
||||
}
|
||||
|
||||
+269
@@ -0,0 +1,269 @@
|
||||
package fun.nojava.module.system.service.impl;
|
||||
|
||||
import fun.nojava.common.enums.UserStatus;
|
||||
import fun.nojava.common.enums.UserType;
|
||||
import fun.nojava.common.exception.BusinessException;
|
||||
import fun.nojava.common.exception.ErrorCode;
|
||||
import fun.nojava.framework.security.JwtTokenProvider;
|
||||
import fun.nojava.framework.security.TokenBlacklistService;
|
||||
import fun.nojava.module.system.dto.LoginDTO;
|
||||
import fun.nojava.module.system.dto.LoginVO;
|
||||
import fun.nojava.module.system.dto.RegisterDTO;
|
||||
import fun.nojava.module.system.entity.LoginLog;
|
||||
import fun.nojava.module.system.entity.User;
|
||||
import fun.nojava.module.system.entity.UserSession;
|
||||
import fun.nojava.module.system.mapper.LoginLogMapper;
|
||||
import fun.nojava.module.system.mapper.UserMapper;
|
||||
import fun.nojava.module.system.mapper.UserSessionMapper;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Tag;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class AuthServiceImplP0P1Test {
|
||||
|
||||
@Mock
|
||||
private UserMapper userMapper;
|
||||
@Mock
|
||||
private LoginLogMapper loginLogMapper;
|
||||
@Mock
|
||||
private UserSessionMapper userSessionMapper;
|
||||
@Mock
|
||||
private PasswordEncoder passwordEncoder;
|
||||
@Mock
|
||||
private JwtTokenProvider jwtTokenProvider;
|
||||
@Mock
|
||||
private TokenBlacklistService tokenBlacklistService;
|
||||
|
||||
@InjectMocks
|
||||
private AuthServiceImpl authService;
|
||||
|
||||
@Test
|
||||
@Tag("p0")
|
||||
@DisplayName("TC-AC-USER-LOGIN-01: 用户密码正确时应登录成功并写入登录日志")
|
||||
void shouldLoginSuccessfullyAndRecordSuccessLog() {
|
||||
User user = buildUser(101L, UserType.USER.getCode(), UserStatus.NORMAL.getCode(), "u@test.dev");
|
||||
LoginDTO dto = new LoginDTO();
|
||||
dto.setEmail("u@test.dev");
|
||||
dto.setPassword("User@123");
|
||||
dto.setDeviceFingerprint("fp-101");
|
||||
dto.setRememberMe(false);
|
||||
|
||||
when(userMapper.selectOne(any())).thenReturn(user);
|
||||
when(passwordEncoder.matches("User@123", user.getPassword())).thenReturn(true);
|
||||
when(jwtTokenProvider.generateAccessToken(101L, UserType.USER.getCode())).thenReturn("access-token");
|
||||
when(jwtTokenProvider.generateRefreshToken(101L)).thenReturn("refresh-token");
|
||||
|
||||
LoginVO result = authService.login(dto, "127.0.0.1", "JUnit");
|
||||
|
||||
assertNotNull(result);
|
||||
assertEquals(101L, result.getUserId());
|
||||
assertEquals("access-token", result.getAccessToken());
|
||||
assertEquals("refresh-token", result.getRefreshToken());
|
||||
|
||||
ArgumentCaptor<LoginLog> logCaptor = ArgumentCaptor.forClass(LoginLog.class);
|
||||
verify(loginLogMapper, times(1)).insert(logCaptor.capture());
|
||||
assertEquals(1, logCaptor.getValue().getStatus());
|
||||
assertEquals("u@test.dev", logCaptor.getValue().getEmail());
|
||||
assertEquals(UserType.USER.getCode(), logCaptor.getValue().getLoginType());
|
||||
assertEquals("fp-101", logCaptor.getValue().getDeviceFingerprint());
|
||||
|
||||
verify(userSessionMapper, times(1)).insert(any(UserSession.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@Tag("p0")
|
||||
@DisplayName("TC-AC-USER-LOGIN-03: 密码错误时应拒绝并写失败日志")
|
||||
void shouldRejectWhenPasswordInvalidAndWriteFailLog() {
|
||||
User user = buildUser(102L, UserType.USER.getCode(), UserStatus.NORMAL.getCode(), "invalid@test.dev");
|
||||
LoginDTO dto = new LoginDTO();
|
||||
dto.setEmail("invalid@test.dev");
|
||||
dto.setPassword("Wrong@123");
|
||||
|
||||
when(userMapper.selectOne(any())).thenReturn(user);
|
||||
when(passwordEncoder.matches("Wrong@123", user.getPassword())).thenReturn(false);
|
||||
|
||||
BusinessException ex = assertThrows(BusinessException.class, () ->
|
||||
authService.login(dto, "127.0.0.1", "JUnit"));
|
||||
|
||||
assertEquals(ErrorCode.PASSWORD_ERROR.getCode(), ex.getCode());
|
||||
|
||||
ArgumentCaptor<LoginLog> logCaptor = ArgumentCaptor.forClass(LoginLog.class);
|
||||
verify(loginLogMapper).insert(logCaptor.capture());
|
||||
assertEquals(0, logCaptor.getValue().getStatus());
|
||||
assertEquals("密码错误", logCaptor.getValue().getFailReason());
|
||||
assertEquals(1, user.getLoginFailCount());
|
||||
verify(userMapper).updateById(user);
|
||||
verify(userSessionMapper, never()).insert(org.mockito.ArgumentMatchers.<UserSession>any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@Tag("p0")
|
||||
@DisplayName("TC-AC-USER-REG-02: 重复邮箱注册应被拒绝")
|
||||
void shouldRejectDuplicateEmailRegister() {
|
||||
RegisterDTO dto = new RegisterDTO();
|
||||
dto.setEmail("dup@test.dev");
|
||||
dto.setPassword("Dup@1234");
|
||||
dto.setNickname("dup");
|
||||
|
||||
when(userMapper.selectCount(any())).thenReturn(1L);
|
||||
|
||||
BusinessException ex = assertThrows(BusinessException.class, () -> authService.register(dto));
|
||||
|
||||
assertEquals(ErrorCode.EMAIL_EXISTS.getCode(), ex.getCode());
|
||||
verify(userMapper, never()).insert(org.mockito.ArgumentMatchers.<User>any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@Tag("p0")
|
||||
@DisplayName("TC-NF-SEC-01: 注册密码应经编码后再入库")
|
||||
void shouldEncodePasswordOnRegister() {
|
||||
RegisterDTO dto = new RegisterDTO();
|
||||
dto.setEmail("new@test.dev");
|
||||
dto.setPassword("Strong@123");
|
||||
dto.setNickname("newbie");
|
||||
|
||||
when(userMapper.selectCount(any())).thenReturn(0L);
|
||||
when(passwordEncoder.encode("Strong@123")).thenReturn("$2a$mocked");
|
||||
|
||||
authService.register(dto);
|
||||
|
||||
ArgumentCaptor<User> userCaptor = ArgumentCaptor.forClass(User.class);
|
||||
verify(userMapper).insert(userCaptor.capture());
|
||||
assertEquals("$2a$mocked", userCaptor.getValue().getPassword());
|
||||
assertEquals(UserType.USER.getCode(), userCaptor.getValue().getType());
|
||||
}
|
||||
|
||||
@Test
|
||||
@Tag("p1")
|
||||
@DisplayName("TC-AC-ADM-LOGIN-01: 非管理员账号调用管理员登录应被拒绝")
|
||||
void shouldRejectNonAdminOnAdminLogin() {
|
||||
User user = buildUser(103L, UserType.USER.getCode(), UserStatus.NORMAL.getCode(), "normal@test.dev");
|
||||
LoginDTO dto = new LoginDTO();
|
||||
dto.setEmail("normal@test.dev");
|
||||
dto.setPassword("User@123");
|
||||
dto.setRememberMe(false);
|
||||
|
||||
when(userMapper.selectOne(any())).thenReturn(user);
|
||||
|
||||
BusinessException ex = assertThrows(BusinessException.class, () ->
|
||||
authService.adminLogin(dto, "127.0.0.1", "JUnit"));
|
||||
|
||||
assertEquals(ErrorCode.PERMISSION_DENIED.getCode(), ex.getCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
@Tag("p0")
|
||||
@DisplayName("TC-AC-USER-LOGIN-06: 勾选 rememberMe 会话应接近14天")
|
||||
void shouldCreateRememberMeSessionFor14Days() {
|
||||
User user = buildUser(104L, UserType.USER.getCode(), UserStatus.NORMAL.getCode(), "remember@test.dev");
|
||||
LoginDTO dto = new LoginDTO();
|
||||
dto.setEmail("remember@test.dev");
|
||||
dto.setPassword("User@123");
|
||||
dto.setRememberMe(true);
|
||||
|
||||
when(userMapper.selectOne(any())).thenReturn(user);
|
||||
when(passwordEncoder.matches("User@123", user.getPassword())).thenReturn(true);
|
||||
when(jwtTokenProvider.generateAccessToken(104L, UserType.USER.getCode())).thenReturn("access-token");
|
||||
when(jwtTokenProvider.generateRefreshToken(104L)).thenReturn("refresh-token");
|
||||
|
||||
LocalDateTime before = LocalDateTime.now();
|
||||
authService.login(dto, "127.0.0.1", "JUnit");
|
||||
LocalDateTime after = LocalDateTime.now();
|
||||
|
||||
ArgumentCaptor<UserSession> sessionCaptor = ArgumentCaptor.forClass(UserSession.class);
|
||||
verify(userSessionMapper).insert(sessionCaptor.capture());
|
||||
|
||||
LocalDateTime expiresAt = sessionCaptor.getValue().getExpiresAt();
|
||||
long secondsFromBefore = java.time.Duration.between(before, expiresAt).getSeconds();
|
||||
long secondsFromAfter = java.time.Duration.between(after, expiresAt).getSeconds();
|
||||
assertTrue(secondsFromBefore <= 1_209_600L + 2 && secondsFromBefore >= 1_209_600L - 2);
|
||||
assertTrue(secondsFromAfter <= 1_209_600L + 2 && secondsFromAfter >= 1_209_600L - 3);
|
||||
}
|
||||
|
||||
@Test
|
||||
@Tag("p0")
|
||||
@DisplayName("TC-AC-USER-LOGIN-03: 已锁定账号登录应被拒绝")
|
||||
void shouldRejectLockedAccount() {
|
||||
User user = buildUser(105L, UserType.USER.getCode(), UserStatus.LOCKED.getCode(), "locked@test.dev");
|
||||
user.setLockedUntil(LocalDateTime.now().plusMinutes(10));
|
||||
LoginDTO dto = new LoginDTO();
|
||||
dto.setEmail("locked@test.dev");
|
||||
dto.setPassword("User@123");
|
||||
|
||||
when(userMapper.selectOne(any())).thenReturn(user);
|
||||
|
||||
BusinessException ex = assertThrows(BusinessException.class, () ->
|
||||
authService.login(dto, "127.0.0.1", "JUnit"));
|
||||
|
||||
assertEquals(ErrorCode.ACCOUNT_LOCKED.getCode(), ex.getCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
@Tag("p0")
|
||||
@DisplayName("TC-AC-ADM-LOGIN-02: 连续3次错密后第4次应强制验证码")
|
||||
void shouldRequireCaptchaAfterThreeFailures() {
|
||||
User user = buildUser(106L, UserType.ADMIN.getCode(), UserStatus.NORMAL.getCode(), "admin@test.dev");
|
||||
user.setLoginFailCount(3);
|
||||
LoginDTO dto = new LoginDTO();
|
||||
dto.setEmail("admin@test.dev");
|
||||
dto.setPassword("Admin@123");
|
||||
|
||||
when(userMapper.selectOne(any())).thenReturn(user);
|
||||
|
||||
BusinessException ex = assertThrows(BusinessException.class, () ->
|
||||
authService.login(dto, "127.0.0.1", "JUnit"));
|
||||
assertEquals(ErrorCode.CAPTCHA_REQUIRED.getCode(), ex.getCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
@Tag("p0")
|
||||
@DisplayName("TC-AC-ADM-LOGIN-03: 连续5次错误后应锁定账号")
|
||||
void shouldLockAccountAfterFiveFailures() {
|
||||
User user = buildUser(107L, UserType.ADMIN.getCode(), UserStatus.NORMAL.getCode(), "admin-lock@test.dev");
|
||||
user.setLoginFailCount(4);
|
||||
|
||||
LoginDTO dto = new LoginDTO();
|
||||
dto.setEmail("admin-lock@test.dev");
|
||||
dto.setPassword("Wrong@123");
|
||||
dto.setCaptchaKey("k1");
|
||||
dto.setCaptchaCode("1234");
|
||||
|
||||
when(userMapper.selectOne(any())).thenReturn(user);
|
||||
when(passwordEncoder.matches("Wrong@123", user.getPassword())).thenReturn(false);
|
||||
|
||||
BusinessException ex = assertThrows(BusinessException.class, () ->
|
||||
authService.login(dto, "127.0.0.1", "JUnit"));
|
||||
assertEquals(ErrorCode.PASSWORD_ERROR.getCode(), ex.getCode());
|
||||
assertEquals(UserStatus.LOCKED.getCode(), user.getStatus());
|
||||
assertNotNull(user.getLockedUntil());
|
||||
long minutes = ChronoUnit.MINUTES.between(LocalDateTime.now(), user.getLockedUntil());
|
||||
assertTrue(minutes >= 29 && minutes <= 30);
|
||||
verify(userMapper).updateById(user);
|
||||
}
|
||||
|
||||
private User buildUser(Long id, Integer type, Integer status, String email) {
|
||||
User user = new User();
|
||||
user.setId(id);
|
||||
user.setType(type);
|
||||
user.setStatus(status);
|
||||
user.setEmail(email);
|
||||
user.setPassword("$2a$encoded");
|
||||
user.setNickname("tester");
|
||||
user.setLoginFailCount(0);
|
||||
return user;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user