forked from baskbuddy/zhangshun
34 changed files with 3771 additions and 125 deletions
@ -0,0 +1,167 @@ |
|||
# S03「生产默认账号、简历隐私与 Actuator 隔离」独立代码复验及真实 MySQL/Docker 黑盒验收 |
|||
|
|||
日期:2026-09-15 |
|||
分支:`feat/s03-production-privacy` |
|||
结论:**FAIL —— 已复现具体缺陷(阶段 I-1:业务端口 Actuator 路径非 404),S03 不可冻结;报告包含下一轮定向整改指令。** |
|||
|
|||
## 1. Git 基线与工作区状态 |
|||
|
|||
- 当前分支 `feat/s03-production-privacy`;HEAD `f399060 feat: isolate and revoke authentication token sessions`。 |
|||
- 暂存区为空;全部 S03 修改处于未提交(M/??)状态。 |
|||
- `git diff --check` 通过;`git diff --name-only -- mydreamjob-server/src/main/resources/db/migration` 为空(V1–V42 无改动)。 |
|||
- 唯一新增迁移:`V43__disable_legacy_seed_accounts.sql`。 |
|||
- 未执行 `git add -A`/commit/push/reset/restore/checkout/clean;未删除历史文件。 |
|||
|
|||
## 2. 独立代码审查(阶段 A) |
|||
|
|||
| 项 | 结论 | 关键证据 | |
|||
|---|---|---| |
|||
| A1 V43 迁移安全 | PASS | 3 条 UPDATE(无 DELETE/DROP/TRUNCATE);不按 id 匹配;每条同时匹配原始手机号+邮箱+公开 bcrypt 哈希+主角色+`is_active=1`;只置 `is_active=0`;不碰 `user_roles`;指纹被改的账号不命中;V1–V42 未修改 | |
|||
| A2 管理员初始化装配 | PASS | 默认关闭 + 字面量 `"true"` 双重校验;手机号无默认值、非法即启动失败;已有活动 ADMIN 无副作用跳过;不设密码(`password_hash=NULL`)/不发 Token/不发短信/无公网接口;日志与异常文本固定且不含手机号(`AdminBootstrapRunner.ERROR_INVALID_PHONE/ERROR_PHONE_IN_USE`) | |
|||
| A3 事务与并发 | PASS | `JdbcTemplate + REQUIRES_NEW` 独立事务,users 与 user_roles 同事务回滚;刻意绕过 Hibernate 避免 `SqlExceptionHelper` 记录绑定手机号;异常被捕获只返回 `CONFLICT`,原始 cause 不传播;冲突后仅 `isValidBootstrapAdmin` 全条件匹配才幂等 | |
|||
| A4 简历授权失败关闭 | PASS | `CompanyResumeAccessPolicy`:归属全部服务端推导;无记录/PRIVATE 按 PRIVATE;APPLIED_COMPANIES 严格白名单(SUB/VIEWED/INTERVIEW/HIRED,区分大小写,未知/空白/null/WITHDRAWN/REJECTED 全拒);VERIFIED_COMPANIES 要求 `verified=true` 且岗位归属;授权失败发生在文件读取前 | |
|||
| A5 文件与响应安全 | PASS | 授权后读文件;`Content-Disposition attachment+UTF-8`、`Cache-Control: no-store`、Content-Type 白名单回退 octet-stream;响应不含 resumeUrl/storagePath/studentUserId | |
|||
| A6 Actuator 隔离(设计层面) | PASS* | 独立管理端口默认 127.0.0.1;仅暴露 health/info/metrics/prometheus;`show-details/components=never`;Prometheus 显式开启;不当 JWT 运维认证。*注:阶段 I-1 真实验收发现业务端口行为不达标,见第 12 节 | |
|||
| A7 副作用测试真实性 | PASS | `CompanyResumeDownloadIntegrationTest` 行级字段快照(如 users: id/phone/email/role/is_active/is_verified/updated_at),非 COUNT/SUM;含反证测试(单行 status 变化必须被发现)与 15 表字段覆盖断言 | |
|||
|
|||
## 3. 自动化独立复验(阶段 B) |
|||
|
|||
| 命令 | Tests | Failures | Errors | Skipped | 结果 | |
|||
|---|---:|---:|---:|---:|---| |
|||
| `mvn clean verify`(实测,非沿用历史) | 1834 | 0 | 0 | 0 | BUILD SUCCESS | |
|||
| `python docs\verify_openapi.py` | — | — | — | — | paths=176 operations=214,All checks passed | |
|||
|
|||
## 4. Docker/MySQL 环境(阶段 C) |
|||
|
|||
- Docker Engine 29.6.2;容器由负责人环境启动(本次仅 `docker start` 已存在容器,未重建、未删 volume)。 |
|||
- MySQL 8.4.11(127.0.0.1:3307,库 `mydreamjob`);Redis PONG;RabbitMQ healthy。 |
|||
- Flyway V1–V42 全部 success=1。 |
|||
|
|||
## 5. V43 真实迁移验收(阶段 D) |
|||
|
|||
- 迁移前三个种子账号脱敏快照:role=ADMIN/STUDENT/COMPANY,均 `is_active=1`,指纹与 V1_1 原始值完全匹配,`user_roles` 各 1 条,关联业务数据 4(applications)/21(jobs)/2(profiles)/1(company)。 |
|||
- 最新 JAR + dev(`ddl-auto=validate`)启动后 `flyway_schema_history`:`V43 disable legacy seed accounts success=1`,无失败迁移,V1–V42 校验和未变。 |
|||
- 三个种子账号 `is_active` 1→0;users 行仍存在;`user_roles` 仍存在;关联业务数据未删除。 |
|||
- 指纹偏差隔离夹具(7976/7977,改过手机号与哈希)在 V43 后仍 `is_active=1`,未误伤。 |
|||
- 原种子账号(demo 口令)登录返回 400 且无法取得 Token(已禁用账号不能建立会话),达成"不能取得 Token"目标。 |
|||
|
|||
## 6. 管理员初始化配置矩阵(阶段 E,真实 MySQL) |
|||
|
|||
| 用例 | 结果 | 实测证据 | |
|||
|---|---|---| |
|||
| E1 默认关闭 | PASS | 应用正常启动且未创建任何 ADMIN | |
|||
| E2 enabled=true+非法手机号 | PASS | 启动失败,固定文案 `admin bootstrap rejected: configured phone is invalid` | |
|||
| E3 无活动ADMIN+空闲手机号 | PASS | users 恰 1 行(role=ADMIN、active、verified、`password_hash=NULL`);`user_roles` 仅 ADMIN;无 Token/短信/通知;日志无手机号 | |
|||
| E4 重复启动同配置 | PASS | `skipped`;users 与 ADMIN 角色计数均不增 | |
|||
| E5 手机号被 STUDENT 占用 | PASS | 启动失败固定文案 `phone is already in use`;该 STUDENT 未被提权/复活/覆盖 | |
|||
| E6 真实双 JAR 并发同手机号 | PASS | 两个独立进程 + 屏障同时初始化:最终 users 恰 1 行、ADMIN 角色恰 1 条;一方创建、一方安全幂等跳过;无手机号泄漏;无 `SqlExceptionHelper` 唯一键日志 | |
|||
|
|||
前置与后置:临时禁用 20 个历史验收 ADMIN(id 逐一记录)并以精确 UPDATE 恢复原状;E3/E6 新建的验收 ADMIN 置为 inactive,未删除。 |
|||
|
|||
## 7. 启动最新 JAR 与双端口(阶段 F) |
|||
|
|||
- `Started MyDreamjobApplication`;无 Flyway/schema-validation 错误。 |
|||
- 8080 与 8081 均监听;**8081 仅绑定 127.0.0.1**。 |
|||
- `GET /api/v1/health` → 200/UP(仅最小状态,无 db/redis/rabbit/连接池字段)。 |
|||
- 运行时 OpenAPI:paths=176,operations=214,与静态一致。 |
|||
|
|||
## 8. 企业简历真实 HTTP 权限矩阵(阶段 G / H) |
|||
|
|||
夹具:3 家企业(认证/未认证/其他)、6 名学生(APPLIED_COMPANIES·SUBMITTED、PRIVATE、无隐私记录、VERIFIED_COMPANIES·未认证企业、APPLIED_COMPANIES·WITHDRAWN、VERIFIED_COMPANIES·已认证企业),真实简历文件进存储基目录,投递/隐私由服务端推导。 |
|||
|
|||
| 用例 | HTTP | 期望 | 结果 | |
|||
|---|---|---|---| |
|||
| 匿名 | 401 | 401 | PASS | |
|||
| STUDENT Token | 403/2002 | 403 | PASS | |
|||
| ADMIN Token | 403/2002 | 403 | PASS | |
|||
| 其他企业 | 403/2002 | 403 | PASS | |
|||
| 未认证企业(岗位不属于) | 403/2002 | 403 | PASS | |
|||
| 认证企业+APPLIED+SUBMITTED | 200 | 200 | PASS(下文字节/头断言) | |
|||
| PRIVATE 隐私 | 403/2002 | 403 | PASS | |
|||
| 无隐私记录(默认 PRIVATE) | 403/2002 | 403 | PASS | |
|||
| VERIFIED_COMPANIES+未认证企业 | 403/2002 | 403 | PASS | |
|||
| APPLIED_COMPANIES+WITHDRAWN | 403/2002 | 403 | PASS | |
|||
| VERIFIED_COMPANIES+已认证企业 | 200 | 200 | PASS(字节一致) | |
|||
| 投递不存在 | 404/8002 | 404 | PASS | |
|||
|
|||
成功下载断言:文件字节与夹具逐字节一致(`RESUME-FIXTURE-A/F-20260915`,字节级比较 PASS);`Content-Disposition: attachment; filename="=?UTF-8?Q?...?="`(净化文件名);`Cache-Control: no-store`;Content-Type 走白名单;响应不含存储路径/resumeUrl/studentUserId/手机号/邮箱。 |
|||
日志/审计扫描:未发现简历正文标记、本地路径、S03 测试手机号、密钥等禁止字段。 |
|||
|
|||
## 9. Actuator 双端口真实验收(阶段 I) |
|||
|
|||
**管理端口 127.0.0.1:8081 — 全部 PASS** |
|||
- `/actuator/health` 200,JSON 仅含 `status`(最小化,无 components/db/redis/rabbit/diskSpace/hikari)。 |
|||
- `/actuator/metrics` 200(含 names 数组);`/actuator/prometheus` 200(Prometheus 文本、含 `jvm_memory_used_bytes` 等稳定指标)。 |
|||
- `/actuator/env`、`/beans`、`/configprops`、`/heapdump`、`/threaddump` 均不可读(401)。 |
|||
- 端口仅绑定 127.0.0.1;prometheus 文本未发现密码/Token/Bearer 等敏感值。 |
|||
|
|||
**业务端口 8080 — FAIL(本轮关键缺陷)** |
|||
|
|||
| 路径 | 匿名 | 带 STUDENT Token(有效) | |
|||
|---|---|---| |
|||
| `/actuator` | 401 | 500 | |
|||
| `/actuator/health` | 500 | 500 | |
|||
| `/actuator/metrics` | 500 | 500 | |
|||
| `/actuator/info` | 500 | 500 | |
|||
| `/actuator/prometheus` | 500 | 500 | |
|||
| `/actuator/env` | 401 | 500 | |
|||
| `/actuator/beans` | 401 | 500 | |
|||
|
|||
任务书要求这些路径**无论匿名或带 Token 一律 404**;实测为 401/500。根因:`SecurityConfig` 对 `/actuator/health/**` 等显式 `permitAll`,但业务端口并不注册 Actuator 端点,请求落入 MVC 静态资源解析抛 `NoResourceFoundException`,被 `GlobalExceptionHandler` 通用 handler 映射为 500;未放行的 `/actuator`、`/actuator/env`、`/actuator/beans` 在匿名时被安全层拦为 401,带 Token 后同样 500。 |
|||
同时,`ActuatorIsolationIntegrationTest.businessPortDoesNotExposeActuatorEndpoints` 断言这些路径为 404 且在 H2 测试环境下通过——**测试环境(H2、无独立管理端口随机化 etc.)与真实 MySQL + 完整 Security 配置的生产行为不一致,测试未能捕获 401/500 差异**,属测试真实性缺口。 |
|||
|
|||
## 10. 副作用(阶段 J) |
|||
|
|||
下载成功、下载拒绝、下载 404 三类请求前后,对 15 张表(users、user_roles、student_profiles、resume_versions、applications、jobs、companies、auth_tokens、audit_logs、candidate_dimension_scores、notifications、messages、scoring_risk_events、scoring_risk_actions、talent_invitations)作行级全量快照对比:**完全一致,无任何变化**。PASS。 |
|||
|
|||
## 11. RabbitMQ(阶段 K) |
|||
|
|||
4 个队列真实存在且 0 积压:`queue.sms.verification`、`queue.notification`、`queue.audit`、`queue.notification.dlq`(messages=0、ready=0、unacked=0)。管理员初始化与简历下载均未产生新增消息。PASS。 |
|||
|
|||
## 12. OpenAPI(阶段 L) |
|||
|
|||
静态 `verify_openapi.py`:176 paths / 214 operations,全部通过(含 S03 简历下载契约、S02/S03 校验)。运行时 `/api/v1/v3/api-docs`:176/214 与静态一致。新端点仅 `GET /company/candidates/{applicationId}/resume`,无 requestBody、返回文件流;企业 DTO 不含 resumeUrl/storagePath/studentUserId/phone/email。PASS。 |
|||
|
|||
## 13. PASS / FAIL / SKIP 清单 |
|||
|
|||
- PASS:阶段 A(A1–A7);阶段 B(1834 测试、OpenAPI 静态);阶段 C;阶段 D(V43 真实迁移与种子禁用、不误伤、登录不可用);阶段 E(E1–E6);阶段 F;阶段 G/H(12/12、字节与头断言、日志脱敏);阶段 I-2 管理端口;阶段 J;阶段 K;阶段 L。 |
|||
- **FAIL:阶段 I-1(业务端口 Actuator 路径 401/500 而非 404)+ 关联测试真实性缺口(ActuatorIsolationIntegrationTest 与生产行为不一致)。** |
|||
- SKIP:无(核心项全部执行;仅"更大规模容量压测"不在本轮范围,不构成阻断)。 |
|||
|
|||
## 14. 剩余风险 |
|||
|
|||
- 业务端口对 `/actuator/health` 等返回 500:属于错误响应泄漏(对探测者暴露服务器异常迹象),且不满足契约的 404 语义;修复后需补"业务端口带 Token 亦为 404"的真实 HTTP 覆盖。 |
|||
- 登录接口对已禁用账号返回 400 而非 403/401(无法取得 Token 已满足;状态码语义差异建议与契约对齐)。 |
|||
- 管理端口依赖网络隔离运维边界,生产中需确认部署层网络策略确实限制 8081 仅内网可访问。 |
|||
|
|||
## 15. 是否允许冻结 S03 |
|||
|
|||
**不允许。** 建议整改指令(下一轮定向修复,不修改本报告已执行的验收环境): |
|||
|
|||
1. 业务端口对未注册 Actuator 路径统一返回 404: |
|||
- 在 `GlobalExceptionHandler` 增加 `NoResourceFoundException` 的精确映射(404),避免落入通用 500;或 |
|||
- 移除 `SecurityConfig` 中业务端口对 `/actuator/**` 的 `permitAll` 并显式声明业务端口 Actuator 不可达(任选其一,保持管理端口行为不变)。 |
|||
2. 增加真实 MySQL 环境下的 HTTP 断言:业务端口 `/actuator`、`/actuator/health`、`/actuator/metrics`、`/actuator/prometheus`、`/actuator/env`、`/actuator/beans` 在匿名与有效 STUDENT/COMPANY/ADMIN Token 下均为 404;并说明测试环境中为何出现 H2 404 而真实 MySQL 401/500 的差异,消除测试假绿。 |
|||
3. 修复后在最新 JAR + 真实 MySQL 上重跑阶段 I(业务端口 404 矩阵 + 管理端口只读矩阵)与 1834 项全量回归。 |
|||
|
|||
本报告不包含数据库密码、JWT、验证码、完整手机号、邮箱、验证手机号或简历正文。验收脚本与日志保留在 `backend-infra/s03-*.ps1|.py|.log`,夹具数据按"本轮唯一前缀/精确主键"清理并记录;`audit_logs` 未做删除。 |
|||
|
|||
## 16. Actuator 404 增量复验结果(定向整改后) |
|||
|
|||
上一节记录了初次独立验收的唯一阻断:业务端口 8080 上 `/actuator/**` 匿名/带 Token 行为为 401/500 而非任务书要求的 404,且 H2 测试与生产行为不一致。针对该阻断完成定向整改后,使用最新 `mvn clean verify` JAR 与真实 MySQL 8.4.11 复验: |
|||
|
|||
1. **整改方式 —— SecurityConfig**:原对 `/actuator/health/**`、`/actuator/info`、`/actuator/metrics/**`、`/actuator/prometheus` 的四个 `permitAll` 收敛为单一 `.requestMatchers("/actuator/**").permitAll()`。目的**非暴露 Actuator**(Actuator 仍只注册在独立管理端口),而是让所有 `/actuator/**` 请求进入 MVC 的不存在资源处理,消除匿名请求先被认证层转 401 的分支;业务端口未新增任何 Actuator Controller 或端点映射。 |
|||
2. **整改方式 —— GlobalExceptionHandler**:新增 `@ExceptionHandler(NoResourceFoundException.class)`,固定返回 HTTP 404、`code=404`、`message=not found`、`requestId`;不回显异常 message、请求路径、本地路径或堆栈;日志仅固定文案 + 归一化 requestId;未新增 ErrorCode。 |
|||
3. **业务端口矩阵(真实 HTTP)**:14 条路径(`/actuator`、`/actuator/`、`/actuator/health`、`/actuator/health/liveness`、`/actuator/info`、`/actuator/metrics`、`/actuator/metrics/jvm.memory.used`、`/actuator/prometheus`、`/actuator/env`、`/actuator/beans`、`/actuator/configprops`、`/actuator/heapdump`、`/actuator/threaddump`、`/actuator/unknown`)× 6 种身份(匿名 / STUDENT / COMPANY / ADMIN / 非法 Token / Refresh Token)共 **84 个组合全部返回 404**,且响应递归扫描无任何异常类名、框架包名、路径、Token、手机号等泄漏。 |
|||
4. **管理端口 127.0.0.1:8081**:health(200,最小 JSON,仅 `status`)/ info(200)/ metrics(200,含 names)/ prometheus(200,含稳定 JVM 指标)白名单端点正常;env / beans / configprops / heapdump / threaddump / unknown 全部 404(未暴露端点未被修复带成 200);8081 仅绑定 127.0.0.1。 |
|||
5. **副作用**:复验期间日志无 `NoResourceFoundException` 堆栈、无 ERROR;RabbitMQ 4 个队列(sms.verification / notification / audit / notification.dlq)0 积压;数据库无任何改动。 |
|||
6. **自动化**:定向(Actuator/Security/GlobalExceptionHandler)61/61;全量 `mvn clean verify` **1836/1836**(含新增 2 个 Actuator 复验测试),Failures=0、Errors=0、Skipped=0;OpenAPI 静态与运行时均为 **176 paths / 214 operations**,Actuator 不进入业务 OpenAPI。 |
|||
7. **一致性**:V1–V42 未修改,V43 仍为唯一新增迁移;未新增 ErrorCode、公网业务接口、表或索引;应用进程已停止,当前未提交、未推送。 |
|||
|
|||
## 17. 最终结论 |
|||
|
|||
- 初次独立验收发现业务端口 Actuator 401/500 阻断; |
|||
- 完成定向整改(SecurityConfig 统一 `/actuator/**` 语义 + GlobalExceptionHandler 安全 404 映射 + 失败测试补齐); |
|||
- 最新 JAR + 真实 MySQL/Docker 增量复验全部通过。 |
|||
|
|||
**S03 独立代码复验和真实 MySQL/Docker 黑盒全部通过,可以进入精确暂存检查点。** |
|||
@ -0,0 +1,167 @@ |
|||
# S03 生产配置、简历隐私与 Actuator 隔离设计 |
|||
|
|||
状态:设计已确认,待按独立检查点实施。 |
|||
|
|||
## 1. 目标与边界 |
|||
|
|||
S03 只处理三类高风险问题: |
|||
|
|||
1. 禁用仍保持原始状态的通用 Flyway 演示账号,并提供显式、一次性的管理员初始化能力; |
|||
2. 让 `resumeVisibility` 在企业读取简历文件的正式边界真正生效; |
|||
3. 将 Actuator 健康探针与指标端点隔离,防止普通业务用户读取运行指标。 |
|||
|
|||
S03 不修改 Access/Refresh Token 语义,不修改评分、排名、推荐、人才发现、邀约、Offer、申诉、消息撤回、简历上传与学生本人下载语义,不做性能重构,不进入 Q01/R01。 |
|||
|
|||
## 2. 已核实的现状 |
|||
|
|||
- `V1_1__seed_data.sql` 无环境隔离地插入已知 ADMIN、STUDENT、COMPANY 账号及公开口令哈希。 |
|||
- V1–V42 已经执行,不允许修改校验和;S03 使用新的 V43 迁移。 |
|||
- `student_privacy_settings.resume_visibility` 已支持 `PRIVATE`、`APPLIED_COMPANIES`、`VERIFIED_COMPANIES`,默认 `PRIVATE`。 |
|||
- `PrivacySettingsService.canCompanyViewResume(...)` 没有生产调用方。 |
|||
- 该方法的 `APPLIED_COMPANIES` 分支没有核对投递岗位归属 `companyId`,不能直接作为授权边界使用。 |
|||
- 企业候选人 DTO 当前主动排除了 `resumeUrl`;当前没有企业下载简历文件的正式接口。 |
|||
- `/actuator/health/**` 和 `/actuator/info` 公开;`metrics/prometheus` 被暴露后会落入普通 `authenticated()`,任何登录用户均可能访问。 |
|||
|
|||
## 3. S03-A 历史种子账号治理 |
|||
|
|||
### 3.1 V43 迁移原则 |
|||
|
|||
新增且仅新增: |
|||
|
|||
`V43__disable_legacy_seed_accounts.sql` |
|||
|
|||
V43 只能禁用仍与 V1_1 原始指纹完全一致的三条种子账号。匹配至少同时包含: |
|||
|
|||
- 原始手机号; |
|||
- 原始邮箱; |
|||
- 原始公开 bcrypt 哈希; |
|||
- 原始主角色; |
|||
- `is_active=1`。 |
|||
|
|||
不得依赖固定 `id=1/2/3`。不得删除用户、角色、审计或业务数据,不得重置自增。不得修改已被运营人员改过手机号、邮箱、哈希或角色的账号。 |
|||
|
|||
命中后仅将 `users.is_active` 置为 `0`。保留记录与关联关系,用于历史数据完整性和审计追溯。由于认证签发链已拒绝 inactive 用户,已知账号不能再取得会话。 |
|||
|
|||
迁移必须幂等,并分别覆盖 ADMIN、STUDENT、COMPANY 三条原始种子。迁移报告只记录命中数量,不输出完整手机号、邮箱或哈希。 |
|||
|
|||
### 3.2 管理员一次性初始化 |
|||
|
|||
新增显式开关,默认关闭: |
|||
|
|||
- `app.bootstrap.admin.enabled=${ADMIN_BOOTSTRAP_ENABLED:false}` |
|||
- `app.bootstrap.admin.phone=${ADMIN_BOOTSTRAP_PHONE:}` |
|||
- `app.bootstrap.admin.nickname=${ADMIN_BOOTSTRAP_NICKNAME:平台管理员}` |
|||
|
|||
初始化器仅在开关严格为 `true` 时装配。普通启动不得隐式创建管理员。 |
|||
|
|||
初始化规则: |
|||
|
|||
1. 手机号必须通过现有手机号格式校验;不得写日志、异常详情或审计 details。 |
|||
2. 数据库已有活动 ADMIN 时安全跳过,不创建第二个管理员。 |
|||
3. 数据库没有活动 ADMIN 且手机号不存在时,创建 `active=true`、`verified=true` 的 ADMIN,并同时写入 `users.role` 与 `user_roles`。 |
|||
4. 手机号已属于 STUDENT/COMPANY、inactive 用户或角色信息不一致时启动失败,禁止自动提权或复活。 |
|||
5. 相同配置重复启动必须幂等。 |
|||
6. 初始化成功后应提示固定文本,部署方必须移除开关;日志不得包含手机号。 |
|||
7. 多实例部署时只允许一个实例带初始化开关启动;数据库唯一手机号约束作为最后防线,冲突必须失败而不是吞掉。 |
|||
|
|||
不新增公网初始化接口,不接受请求参数创建 ADMIN,不设置默认密码。管理员仍使用正式短信认证流程;真实短信供应商未配置时不能以 Mock 绕过。 |
|||
|
|||
## 4. S03-B 简历隐私授权 |
|||
|
|||
### 4.1 唯一新增企业接口 |
|||
|
|||
新增: |
|||
|
|||
`GET /company/candidates/{applicationId}/resume` |
|||
|
|||
该接口返回文件流,不返回 `resumeUrl`、存储相对路径、绝对路径、学生用户 ID、手机号或邮箱。文件名必须继续经过 `ResumeFilenameSanitizer`。 |
|||
|
|||
除该接口外不新增按 `studentUserId`、`candidateKey` 或任意 URL 下载简历的入口。人才发现接口本轮不得直接下载简历。 |
|||
|
|||
### 4.2 授权顺序 |
|||
|
|||
必须在读取文件前依次完成: |
|||
|
|||
1. 从 `SecurityContext` 取得当前用户,要求 COMPANY; |
|||
2. 定位当前用户拥有的 Company; |
|||
3. 查询 `applicationId` 及对应 Job; |
|||
4. 校验 Job 属于当前 Company; |
|||
5. 取得投递对应的 `studentUserId`; |
|||
6. 按隐私设置判定可见性; |
|||
7. 通过后才读取 `student_profiles.resume_url` 和文件内容。 |
|||
|
|||
跨企业投递统一返回既有 403 语义,不通过差异响应泄露归属。隐私拒绝使用 `FORBIDDEN(2002)`。不存在的投递保持既有 `APPLICATION_NOT_FOUND`;授权通过但学生没有简历时使用既有 `PROFILE_NOT_FOUND`,不新增 ErrorCode。 |
|||
|
|||
### 4.3 可见性决策表 |
|||
|
|||
| 设置 | 企业条件 | 投递条件 | 结果 | |
|||
|---|---|---|---| |
|||
| 无设置记录/null | 任意 | 任意 | 403,按 PRIVATE 处理 | |
|||
| PRIVATE | 任意 | 包括已投递、面试、录用 | 403 | |
|||
| APPLIED_COMPANIES | 岗位属于当前企业 | SUBMITTED/VIEWED/INTERVIEW/HIRED | 200 | |
|||
| APPLIED_COMPANIES | 岗位属于当前企业 | WITHDRAWN/REJECTED | 403 | |
|||
| APPLIED_COMPANIES | 其他企业 | 任意 | 403 | |
|||
| VERIFIED_COMPANIES | 当前企业 `verified=true` 且拥有该投递岗位 | 任意合法投递状态 | 200 | |
|||
| VERIFIED_COMPANIES | 未认证企业或其他企业 | 任意 | 403 | |
|||
|
|||
`VERIFIED_COMPANIES` 不是“知道学生 ID 即可查看”。它仍要求合法的候选业务上下文,本轮该上下文限定为当前企业拥有的 `applicationId`。 |
|||
|
|||
### 4.4 代码结构 |
|||
|
|||
将现有布尔方法收束为失败关闭的授权组件,例如 `CompanyResumeAccessPolicy`。授权组件必须校验 company ownership,不能依赖 Controller 先验,也不能把客户端传入的 `studentUserId/companyId` 当成可信事实。 |
|||
|
|||
文件读取放在独立 Service 方法中。Controller 只解析身份、路径参数、构建安全响应头。任何拒绝路径都不得调用 `ResumeStorageService.read()`。 |
|||
|
|||
企业候选人列表与详情 DTO 保持不含 `resumeUrl`。学生本人上传、下载、版本、导出接口完全不变。 |
|||
|
|||
## 5. S03-C Actuator 隔离 |
|||
|
|||
### 5.1 运行边界 |
|||
|
|||
推荐配置: |
|||
|
|||
- `management.server.port=${MANAGEMENT_SERVER_PORT:8081}` |
|||
- `management.server.address=${MANAGEMENT_SERVER_ADDRESS:127.0.0.1}` |
|||
- 仅暴露 `health,info,metrics,prometheus`; |
|||
- `health.show-details=never`; |
|||
- `health.show-components=never`。 |
|||
|
|||
生产默认只绑定回环地址。需要采集 Prometheus 时,由部署层通过受控网络或反向代理访问管理端口,不通过公网业务端口开放。 |
|||
|
|||
### 5.2 业务端口行为 |
|||
|
|||
业务端口不得提供 `/actuator/metrics` 或 `/actuator/prometheus`。普通 STUDENT、COMPANY、ADMIN 的业务 JWT 不能把管理指标变成公网资源。健康检查继续提供最小 `UP/DOWN` 信息,不返回数据库、Redis、RabbitMQ、磁盘路径或连接池细节。 |
|||
|
|||
本轮不新增运维账号体系,不把 ADMIN 等同于基础设施运维身份。 |
|||
|
|||
## 6. 数据与副作用 |
|||
|
|||
允许的数据库变化仅为: |
|||
|
|||
- V43 对完全匹配的旧种子账号执行 `is_active: 1 -> 0`; |
|||
- 显式管理员初始化首次创建一条 ADMIN 用户和对应角色; |
|||
- 企业成功下载简历如需审计,只允许白名单动作与 `applicationId/companyId`,不得记录路径、文件名、学生 ID、联系方式或简历内容。 |
|||
|
|||
简历访问不得修改 `student_profiles`、`resume_versions`、投递、评分、排名、推荐、邀约、消息、通知或风险表,不发送 RabbitMQ、短信或邮件。 |
|||
|
|||
## 7. 契约与兼容性 |
|||
|
|||
- S02 基线为 175 paths / 213 operations。 |
|||
- 新增一个企业简历下载接口后,预期为 176 paths / 214 operations;最终以运行时 OpenAPI 导出为准。 |
|||
- 不修改既有响应字段,不把 `resumeUrl` 加回任何企业 DTO。 |
|||
- 不新增 ErrorCode。 |
|||
- V43 是唯一新增迁移,V1–V42 不得修改。 |
|||
|
|||
## 8. 验收门槛 |
|||
|
|||
必须同时通过: |
|||
|
|||
- V43 在 H2 快速层和真实 MySQL 8 执行成功; |
|||
- 仅完全匹配的三条原始种子账号被禁用,改过任一指纹的账号不受影响; |
|||
- 管理员初始化关闭、成功、重复、已有 ADMIN、手机号冲突、并发冲突场景; |
|||
- 三种简历可见性及所有投递状态、跨企业、未认证企业、ADMIN/STUDENT/匿名权限矩阵; |
|||
- 拒绝路径零文件读取,企业 JSON/响应头无路径及个人信息泄漏; |
|||
- Actuator 业务端口与管理端口真实启动验证; |
|||
- 全量测试和 `mvn clean verify` 零失败、零错误、零跳过; |
|||
- 静态与运行时 OpenAPI 一致; |
|||
- 真实 MySQL/Docker 黑盒通过后才能冻结。 |
|||
@ -0,0 +1,270 @@ |
|||
# S03 生产配置、简历隐私与 Actuator 隔离实施指令 |
|||
|
|||
项目根目录:`C:\Users\15642\Desktop\zhilai` |
|||
|
|||
候选基线:S02 已完成独立代码复验、真实 MySQL/Docker 黑盒、精确提交并推送;功能分支提交为 `f399060edb2ff778322a4f021c93213f39a6f6bf`。 |
|||
|
|||
本轮必须先完整阅读 `docs/s03-production-security-and-resume-privacy-design.md`。该文档是 S03 权威口径;发生冲突时停止并报告,不得自行扩大语义。 |
|||
|
|||
## 一、开始前核查 |
|||
|
|||
执行: |
|||
|
|||
```powershell |
|||
cd C:\Users\15642\Desktop\zhilai |
|||
git branch --show-current |
|||
git status --short |
|||
git diff --cached --name-only |
|||
git log -5 --oneline |
|||
git diff --check |
|||
``` |
|||
|
|||
要求: |
|||
|
|||
- 从 S02 已提交基线创建独立分支 `feat/s03-production-privacy`; |
|||
- 切分支前确认暂存区和已跟踪工作区为空; |
|||
- 保留全部历史未跟踪脚本、日志、JSON、SQL、xlsx、`.env` 和验收目录; |
|||
- 不执行 `git reset/restore/checkout -- <path>/clean`;创建新分支仅允许 `git switch -c feat/s03-production-privacy`; |
|||
- 不执行 `git add -A`、commit、push; |
|||
- 实施阶段不操作 Docker,不执行真实 MySQL 黑盒; |
|||
- 搜索并完整阅读适用的 `AGENTS.md`,不存在则记录。 |
|||
|
|||
如已有修改与 S03 目标文件重叠,停止并报告。 |
|||
|
|||
## 二、必须完整阅读 |
|||
|
|||
迁移与模型: |
|||
|
|||
- `V1__base_schema.sql` |
|||
- `V1_1__seed_data.sql` |
|||
- `V2__business_schema.sql` |
|||
- `V27__student_privacy_settings.sql` |
|||
- `V42__auth_token_sessions.sql` |
|||
- `User`、`UserRepository`、`Company`、`CompanyRepository` |
|||
- `Application`、`ApplicationRepository`、`Job`、`StudentProfile` |
|||
|
|||
隐私与简历: |
|||
|
|||
- `PrivacySettingsService`、`StudentPrivacySettings`、`ResumeVisibility` |
|||
- `ApplicationController`、`ApplicationService`、`CandidateDetailView` |
|||
- `ProfileController`、`ProfileService` |
|||
- `ResumeStorageService`、`LocalResumeStorageService` |
|||
- `ResumeFilenameSanitizer`、`ResumeFileValidator` |
|||
- 全部 `profile`、`resume`、`privacy`、`application` 测试 |
|||
|
|||
认证与运维: |
|||
|
|||
- `SecurityConfig`、`CurrentUserAccessor` |
|||
- `AuthService`、`AuthSessionService`、`JwtAuthenticationFilter` |
|||
- `application.yml` 及所有 profile 配置 |
|||
- `ErrorCode`、`GlobalExceptionHandler` |
|||
- `pom.xml` 与 Actuator 测试 |
|||
|
|||
文档: |
|||
|
|||
- S03 权威设计文档 |
|||
- `privacy-settings-api.md` |
|||
- `resume-api.md` |
|||
- `frontend-integration.md` |
|||
- `backend-development-roadmap.md` |
|||
- `phase1-scope-and-acceptance.md` |
|||
- `openapi-rc.json` |
|||
- `verify_openapi.py` |
|||
|
|||
先执行搜索并记录所有企业侧简历暴露点: |
|||
|
|||
```powershell |
|||
rg -n -i "resumeUrl|resume_url|download.*resume|resume.*download|ResumeStorageService\.read|canCompanyViewResume|studentUserId" mydreamjob-server/src/main/java |
|||
``` |
|||
|
|||
## 三、S03-A:禁用历史种子账号 |
|||
|
|||
新增且仅新增 `V43__disable_legacy_seed_accounts.sql`。 |
|||
|
|||
要求: |
|||
|
|||
1. 不修改 V1–V42。 |
|||
2. 不按固定 ID 匹配。 |
|||
3. 每条 UPDATE 必须同时匹配 V1_1 中的原手机号、原邮箱、原 bcrypt 哈希、原角色、`is_active=1`。 |
|||
4. ADMIN、STUDENT、COMPANY 三条分别处理。 |
|||
5. 仅更新 `is_active=0`,不删除 users/user_roles,不修改历史业务表。 |
|||
6. 改过手机号、邮箱、哈希或角色的同源账号不得被禁用。 |
|||
7. 重复执行效果稳定。 |
|||
|
|||
新增迁移集成测试,至少覆盖:完整指纹命中、每个单字段偏差不命中、非固定 ID 也能命中、重复执行、关联数据保留、V1–V42 校验和不变。 |
|||
|
|||
## 四、S03-A:一次性管理员初始化 |
|||
|
|||
新增强类型配置和条件装配,例如: |
|||
|
|||
- `AdminBootstrapProperties` |
|||
- `AdminBootstrapRunner` |
|||
- 必要的内部 Service |
|||
|
|||
严格按设计文档执行:默认关闭;只有严格 `true` 才装配;手机号来自环境变量;无默认手机号;不打印手机号;不提供公网接口。 |
|||
|
|||
核心行为: |
|||
|
|||
- 已有活动 ADMIN:无副作用跳过; |
|||
- 无活动 ADMIN + 手机号不存在:创建活动且已验证 ADMIN,`users.role` 与 `user_roles` 一致; |
|||
- 手机号已属于任何用户或角色状态异常:启动失败,禁止提权、复活或覆盖; |
|||
- 重复启动幂等; |
|||
- 多实例冲突安全失败; |
|||
- 不签发 Token、不发送短信、不写默认密码。 |
|||
|
|||
错误信息必须固定且不包含手机号、用户对象、SQL 或环境变量值。日志只能使用固定事件文本。 |
|||
|
|||
必须先写失败测试,覆盖:disabled、missing phone、invalid phone、existing active admin、new admin、duplicate run、existing student/company/inactive user、users/user_roles 一致性、日志脱敏、并发冲突。 |
|||
|
|||
## 五、S03-B:企业简历下载与隐私 |
|||
|
|||
新增唯一公网接口: |
|||
|
|||
`GET /company/candidates/{applicationId}/resume` |
|||
|
|||
推荐新增: |
|||
|
|||
- `CompanyResumeAccessPolicy` |
|||
- `CompanyResumeService` |
|||
- 必要的文件响应 record/DTO |
|||
|
|||
不得将文件访问逻辑塞入现有候选人 DTO,不得返回 `resumeUrl`。 |
|||
|
|||
授权必须在 Service/Policy 中完成,不可信任 Controller 传入的归属信息。按以下顺序:当前 COMPANY 用户 → 当前 Company → Application+Job → Job.companyId → Student → privacy setting → profile/file。 |
|||
|
|||
必须修复现有 `canCompanyViewResume` 的公司归属缺失:`APPLIED_COMPANIES` 不能只校验学生和状态;必须证明 application 对应 Job 属于当前 company。允许替换该方法或让它委托给新 Policy,但不得保留一个可被误用的宽松生产方法。 |
|||
|
|||
语义严格使用设计文档决策表: |
|||
|
|||
- 默认/null 与 PRIVATE:全部企业 403; |
|||
- APPLIED_COMPANIES:仅本企业有效投递,WITHDRAWN/REJECTED 拒绝; |
|||
- VERIFIED_COMPANIES:企业 verified=true 且拥有 application 的岗位,状态不额外限制; |
|||
- 跨企业、未认证、错误角色均拒绝; |
|||
- 人才发现本轮不能借 candidateKey 下载简历。 |
|||
|
|||
响应要求: |
|||
|
|||
- 使用安全 `Content-Disposition`; |
|||
- content type 只能来自已验证的白名单; |
|||
- 不返回内部路径、studentUserId、手机号、邮箱; |
|||
- 拒绝路径不得调用 storage `read`; |
|||
- 文件不存在安全失败,不回显路径; |
|||
- 不缓存私有文件,至少返回 `Cache-Control: no-store`; |
|||
- 不把简历正文写入日志、审计或异常。 |
|||
|
|||
权限测试必须使用真实 SecurityFilterChain/MockMvc,并覆盖 COMPANY、其他企业、未认证企业、STUDENT、ADMIN、匿名。集成测试必须使用真实 H2 Repository 和真实本地临时存储,不得 Mock 掉被验证的授权链。 |
|||
|
|||
对所有状态 `SUBMITTED/VIEWED/INTERVIEW/HIRED/REJECTED/WITHDRAWN` 分别验证三种 visibility。验证企业候选人列表/详情仍不含 `resumeUrl`。 |
|||
|
|||
## 六、S03-C:Actuator 隔离 |
|||
|
|||
按设计新增: |
|||
|
|||
```yaml |
|||
management: |
|||
server: |
|||
port: ${MANAGEMENT_SERVER_PORT:8081} |
|||
address: ${MANAGEMENT_SERVER_ADDRESS:127.0.0.1} |
|||
``` |
|||
|
|||
并将 health details/components 设为 `never`。不要使用业务 JWT 角色代替运维网络隔离,不新增运维公网接口。 |
|||
|
|||
必须验证: |
|||
|
|||
- 业务端口无法访问 metrics/prometheus; |
|||
- 管理端口只绑定配置地址; |
|||
- health 仅返回最小状态,不含 DB/Redis/RabbitMQ/磁盘/连接池详情; |
|||
- metrics/prometheus 只在管理端口存在; |
|||
- STUDENT/COMPANY/ADMIN Token 不会使业务端口指标可见; |
|||
- dev/test/integration-test 不发生随机端口冲突。 |
|||
|
|||
若 Spring Boot 独立 management context 与当前 SecurityFilterChain 行为不一致,先新增真实上下文失败测试,再做最小配置;不得通过重新公开指标绕过。 |
|||
|
|||
## 七、数据与副作用测试 |
|||
|
|||
前后保存完整摘要,至少覆盖: |
|||
|
|||
- users、user_roles; |
|||
- student_profiles、resume_versions; |
|||
- applications、jobs、companies; |
|||
- auth_tokens、audit_logs; |
|||
- candidate_dimension_scores; |
|||
- notifications、messages; |
|||
- scoring_risk_events/actions; |
|||
- talent_invitations。 |
|||
|
|||
企业下载只允许读取;不得修改上述业务表,不发送 RabbitMQ、短信、邮件或通知。若记录下载审计,字段必须为固定白名单且不得包含路径、文件名、学生 ID 或内容。 |
|||
|
|||
## 八、OpenAPI 与文档 |
|||
|
|||
从运行时契约更新 `docs/openapi-rc.json`,不得手工伪造。 |
|||
|
|||
更新: |
|||
|
|||
- `privacy-settings-api.md` |
|||
- `resume-api.md` |
|||
- `frontend-integration.md` |
|||
- `backend-development-roadmap.md` |
|||
- `phase1-scope-and-acceptance.md` |
|||
- `verify_openapi.py` |
|||
|
|||
校验器必须递归断言: |
|||
|
|||
- 只有一个新增路径/operation; |
|||
- 下载接口无 requestBody; |
|||
- 企业候选人 DTO 不含 `resumeUrl/studentUserId/phone/email/storagePath`; |
|||
- 不存在按 studentUserId/candidateKey 的简历下载路径; |
|||
- Actuator 不进入业务 OpenAPI; |
|||
- 预期基线为 176 paths / 214 operations,最终以运行时生成结果为准。 |
|||
|
|||
## 九、验证顺序 |
|||
|
|||
先运行 S03 新增定向测试,再运行: |
|||
|
|||
```powershell |
|||
cd C:\Users\15642\Desktop\zhilai\mydreamjob-server |
|||
mvn -Dtest="*AdminBootstrap*Test,*LegacySeed*Test,*Privacy*Test,*Resume*Test,*CompanyCandidate*Test,*Actuator*Test" test |
|||
mvn -Dtest="*Auth*Test,*Security*Test,*Application*Test,*Company*Test,*Privacy*Test,*Resume*Test" test |
|||
mvn test |
|||
mvn clean verify |
|||
python ..\docs\verify_openapi.py |
|||
``` |
|||
|
|||
最后执行: |
|||
|
|||
```powershell |
|||
cd C:\Users\15642\Desktop\zhilai |
|||
git diff --check |
|||
git status --short |
|||
git diff --cached --name-only |
|||
git diff --name-only -- mydreamjob-server/src/main/resources/db/migration |
|||
``` |
|||
|
|||
必须满足 Failures=0、Errors=0、Skipped=0、clean verify 成功、静态 OpenAPI 校验通过、暂存区为空、V43 为唯一迁移、未新增 ErrorCode。 |
|||
|
|||
## 十、实施结束状态 |
|||
|
|||
实施结束立即停止,不操作 Docker,不执行真实 MySQL 黑盒,不暂存、不提交、不推送,不进入 Q01。 |
|||
|
|||
最终报告必须列出: |
|||
|
|||
1. V43 精确匹配条件和各账号命中结果; |
|||
2. 被修改过的种子账号不受影响证据; |
|||
3. 管理员初始化配置、幂等、冲突与并发结果; |
|||
4. 是否打印手机号或创建默认密码; |
|||
5. 企业简历权限完整决策矩阵; |
|||
6. `APPLIED_COMPANIES` 公司归属缺陷修复方式; |
|||
7. 拒绝路径文件读取次数; |
|||
8. 完整响应与响应头脱敏结果; |
|||
9. Actuator 业务端口/管理端口结果; |
|||
10. 数据库副作用与 RabbitMQ 结果; |
|||
11. 专项、关联、全量测试数及 Failures/Errors/Skipped; |
|||
12. clean verify 与 OpenAPI 结果; |
|||
13. 精确修改文件和 Git 状态; |
|||
14. 是否新增 Flyway、表、索引、ErrorCode、路径; |
|||
15. 明确未执行 git add -A、commit、push、Docker/MySQL 黑盒; |
|||
16. 明确真实 MySQL/Docker 黑盒仍待独立验收。 |
|||
|
|||
最终状态只能写: |
|||
|
|||
“S03 已实现并完成自动化验证与干净构建,待独立代码复验及真实 MySQL/Docker 黑盒验收。” |
|||
@ -0,0 +1,132 @@ |
|||
package com.mydreamjob.application; |
|||
|
|||
import com.mydreamjob.common.exception.BusinessException; |
|||
import com.mydreamjob.common.exception.ErrorCode; |
|||
import com.mydreamjob.company.Company; |
|||
import com.mydreamjob.company.CompanyRepository; |
|||
import com.mydreamjob.job.Job; |
|||
import com.mydreamjob.job.JobRepository; |
|||
import com.mydreamjob.privacy.ResumeVisibility; |
|||
import com.mydreamjob.privacy.StudentPrivacySettings; |
|||
import com.mydreamjob.privacy.StudentPrivacySettingsRepository; |
|||
import lombok.RequiredArgsConstructor; |
|||
import org.springframework.stereotype.Component; |
|||
|
|||
import java.util.Set; |
|||
|
|||
/** |
|||
* S03-B 企业简历访问授权(失败关闭)。 |
|||
* |
|||
* <p>授权顺序严格按设计 §4.2:当前 COMPANY 用户 → 当前 Company → Application+Job → |
|||
* Job.companyId → Student → privacy setting。归属信息全部由服务端推导, |
|||
* <strong>不信任任何客户端传入的 companyId / studentUserId</strong>。</p> |
|||
* |
|||
* <p>决策表(设计 §4.3):</p> |
|||
* <ul> |
|||
* <li>无设置记录 / null / PRIVATE:拒绝(按 PRIVATE 处理)</li> |
|||
* <li>APPLIED_COMPANIES:岗位必须属于当前企业,且投递状态为 |
|||
* SUBMITTED/VIEWED/INTERVIEW/HIRED;WITHDRAWN/REJECTED 拒绝</li> |
|||
* <li>VERIFIED_COMPANIES:当前企业 {@code verified=true} <strong>且</strong>拥有该投递岗位; |
|||
* 投递状态不额外限制</li> |
|||
* </ul> |
|||
* |
|||
* <p>本类修复了此前 {@code PrivacySettingsService.canCompanyViewResume} 只校验学生与状态、 |
|||
* 不校验企业归属的缺陷。</p> |
|||
*/ |
|||
@Component |
|||
@RequiredArgsConstructor |
|||
public class CompanyResumeAccessPolicy { |
|||
|
|||
/** |
|||
* {@code APPLIED_COMPANIES} 允许的投递状态:**严格白名单**。 |
|||
* |
|||
* <p>不得改为「不是拒绝状态就允许」的黑名单逻辑 —— 那会让 {@code null}、空白、 |
|||
* 未知状态或大小写变体(如 {@code "submitted"})失败开放。状态比较区分大小写。</p> |
|||
*/ |
|||
static final Set<String> APPLIED_COMPANY_VISIBLE_STATUSES = |
|||
Set.of("SUBMITTED", "VIEWED", "INTERVIEW", "HIRED"); |
|||
|
|||
private final CompanyRepository companyRepository; |
|||
private final ApplicationRepository applicationRepository; |
|||
private final JobRepository jobRepository; |
|||
private final StudentPrivacySettingsRepository privacyRepository; |
|||
|
|||
/** 授权通过后返回候选人用户 ID;这是后续读取简历的唯一依据。 */ |
|||
public record Decision(Long studentUserId) { |
|||
} |
|||
|
|||
/** |
|||
* 企业下载接口使用的授权入口:仅凭当前登录公司用户与路径中的 applicationId 完成全部判定。 |
|||
* |
|||
* @throws BusinessException 未拥有企业资料 / 跨企业 / 隐私拒绝 → {@code FORBIDDEN(2002)}; |
|||
* 投递不存在 → {@code APPLICATION_NOT_FOUND(8002)} |
|||
*/ |
|||
public Decision authorizeForDownload(Long companyUserId, Long applicationId) { |
|||
if (companyUserId == null || applicationId == null) { |
|||
throw new BusinessException(ErrorCode.FORBIDDEN); |
|||
} |
|||
Company company = companyRepository.findByOwnerUserId(companyUserId).orElse(null); |
|||
if (company == null) { |
|||
throw new BusinessException(ErrorCode.FORBIDDEN); |
|||
} |
|||
Application application = applicationRepository.findById(applicationId) |
|||
.orElseThrow(() -> new BusinessException(ErrorCode.APPLICATION_NOT_FOUND)); |
|||
|
|||
if (!isVisibleToCompanyInternal(application, company.getId())) { |
|||
throw new BusinessException(ErrorCode.FORBIDDEN); |
|||
} |
|||
return new Decision(application.getStudentUserId()); |
|||
} |
|||
|
|||
/** |
|||
* 既有布尔语义的授权判定(供 {@code PrivacySettingsService} 委托)。 |
|||
* |
|||
* <p>调用方提供的 {@code studentUserId} 只用于一致性校验:必须与投递记录一致, |
|||
* 否则视为不可信并拒绝。</p> |
|||
*/ |
|||
public boolean isVisible(Long studentUserId, Long companyId, Long applicationId) { |
|||
if (studentUserId == null || companyId == null || applicationId == null) { |
|||
return false; |
|||
} |
|||
Application application = applicationRepository.findById(applicationId).orElse(null); |
|||
if (application == null || !studentUserId.equals(application.getStudentUserId())) { |
|||
return false; |
|||
} |
|||
return isVisibleToCompanyInternal(application, companyId); |
|||
} |
|||
|
|||
/** |
|||
* 核心判定:投递所属岗位必须属于该企业,再按学生隐私设置决定可见性。 |
|||
*/ |
|||
private boolean isVisibleToCompanyInternal(Application application, Long companyId) { |
|||
Job job = jobRepository.findById(application.getJobId()).orElse(null); |
|||
if (job == null || !companyId.equals(job.getCompanyId())) { |
|||
// 岗位不存在或不属于当前企业:一律拒绝(含跨企业投递)。
|
|||
return false; |
|||
} |
|||
|
|||
ResumeVisibility visibility = visibilityOf(application.getStudentUserId()); |
|||
switch (visibility) { |
|||
case APPLIED_COMPANIES: |
|||
// 白名单判定(失败关闭):null / 空白 / 未知 / 大小写变体一律拒绝。
|
|||
// 先做 null 判断,避免 Set.of(...).contains(null) 抛 NPE。
|
|||
return application.getStatus() != null |
|||
&& APPLIED_COMPANY_VISIBLE_STATUSES.contains(application.getStatus()); |
|||
case VERIFIED_COMPANIES: |
|||
Company company = companyRepository.findById(companyId).orElse(null); |
|||
return company != null && Boolean.TRUE.equals(company.getVerified()); |
|||
case PRIVATE: |
|||
default: |
|||
// 无设置记录、null 或 PRIVATE:按 PRIVATE 处理。
|
|||
return false; |
|||
} |
|||
} |
|||
|
|||
private ResumeVisibility visibilityOf(Long studentUserId) { |
|||
StudentPrivacySettings settings = privacyRepository.findByStudentUserId(studentUserId).orElse(null); |
|||
if (settings == null || settings.getResumeVisibility() == null) { |
|||
return ResumeVisibility.PRIVATE; |
|||
} |
|||
return settings.getResumeVisibility(); |
|||
} |
|||
} |
|||
@ -0,0 +1,72 @@ |
|||
package com.mydreamjob.application; |
|||
|
|||
import com.mydreamjob.application.dto.CompanyResumeFile; |
|||
import com.mydreamjob.common.exception.BusinessException; |
|||
import com.mydreamjob.common.exception.ErrorCode; |
|||
import com.mydreamjob.profile.StudentProfile; |
|||
import com.mydreamjob.profile.StudentProfileRepository; |
|||
import com.mydreamjob.profile.storage.ResumeFilenameSanitizer; |
|||
import com.mydreamjob.profile.storage.ResumeStorageService; |
|||
import lombok.RequiredArgsConstructor; |
|||
import lombok.extern.slf4j.Slf4j; |
|||
import org.springframework.stereotype.Service; |
|||
import org.springframework.transaction.annotation.Transactional; |
|||
|
|||
import java.util.Set; |
|||
|
|||
/** |
|||
* S03-B 企业读取候选人简历文件。 |
|||
* |
|||
* <p>本类只负责「授权已通过之后」的文件读取与响应构建: |
|||
* 授权全部由 {@link CompanyResumeAccessPolicy} 完成,**任何拒绝路径都不会走到 |
|||
* {@link ResumeStorageService#read}</strong>。</p> |
|||
* |
|||
* <p>简历正文、存储路径、原始文件名与学生标识都不写入日志、审计或异常。</p> |
|||
*/ |
|||
@Slf4j |
|||
@Service |
|||
@RequiredArgsConstructor |
|||
public class CompanyResumeService { |
|||
|
|||
/** Content-Type 白名单:只能来自上传时已校验的三种简历类型。 */ |
|||
static final Set<String> ALLOWED_CONTENT_TYPES = Set.of( |
|||
"application/pdf", |
|||
"application/msword", |
|||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document"); |
|||
|
|||
static final String FALLBACK_CONTENT_TYPE = "application/octet-stream"; |
|||
|
|||
private final CompanyResumeAccessPolicy accessPolicy; |
|||
private final StudentProfileRepository profileRepository; |
|||
private final ResumeStorageService resumeStorageService; |
|||
|
|||
/** |
|||
* 读取该投递对应候选人的简历。 |
|||
* |
|||
* @throws BusinessException 授权失败({@code FORBIDDEN} / {@code APPLICATION_NOT_FOUND}), |
|||
* 或授权通过但学生没有简历({@code PROFILE_NOT_FOUND}) |
|||
*/ |
|||
@Transactional(readOnly = true) |
|||
public CompanyResumeFile load(Long companyUserId, Long applicationId) { |
|||
// 先授权,后读文件:拒绝路径绝不触碰存储。
|
|||
CompanyResumeAccessPolicy.Decision decision = |
|||
accessPolicy.authorizeForDownload(companyUserId, applicationId); |
|||
|
|||
StudentProfile profile = profileRepository.findByUserId(decision.studentUserId()) |
|||
.orElseThrow(() -> new BusinessException(ErrorCode.PROFILE_NOT_FOUND)); |
|||
|
|||
String storagePath = profile.getResumeUrl(); |
|||
if (storagePath == null || storagePath.isBlank()) { |
|||
throw new BusinessException(ErrorCode.PROFILE_NOT_FOUND); |
|||
} |
|||
|
|||
byte[] content = resumeStorageService.read(storagePath); |
|||
String filename = ResumeFilenameSanitizer.forDownload(profile.getResumeOriginalName(), "resume"); |
|||
String contentType = resolveContentType(profile.getResumeContentType()); |
|||
return new CompanyResumeFile(content, filename, contentType); |
|||
} |
|||
|
|||
private String resolveContentType(String stored) { |
|||
return (stored != null && ALLOWED_CONTENT_TYPES.contains(stored)) ? stored : FALLBACK_CONTENT_TYPE; |
|||
} |
|||
} |
|||
@ -0,0 +1,10 @@ |
|||
package com.mydreamjob.application.dto; |
|||
|
|||
/** |
|||
* S03-B 企业简历下载的内部响应载体。 |
|||
* |
|||
* <p>只携带渲染文件响应所必需的三个字段:文件内容、已净化的下载文件名、白名单内的 |
|||
* Content-Type。**不包含**存储路径、学生用户 ID、手机号、邮箱或简历正文文本。</p> |
|||
*/ |
|||
public record CompanyResumeFile(byte[] content, String filename, String contentType) { |
|||
} |
|||
@ -0,0 +1,105 @@ |
|||
package com.mydreamjob.auth; |
|||
|
|||
import org.springframework.dao.DataAccessException; |
|||
import org.springframework.jdbc.core.JdbcTemplate; |
|||
import org.springframework.stereotype.Component; |
|||
import org.springframework.transaction.support.TransactionTemplate; |
|||
|
|||
import java.util.List; |
|||
import java.util.Optional; |
|||
|
|||
/** |
|||
* S03 定向整改:管理员账号创建的**独立事务组件**。 |
|||
* |
|||
* <p>为什么不用 JPA/Hibernate:并发初始化时数据库唯一约束会成为正常并发控制手段, |
|||
* 而 Hibernate 的 {@code SqlExceptionHelper} 会在业务代码捕获异常**之前**以 ERROR 级别 |
|||
* 记录整条语句与绑定值 —— 管理员手机号因此进入生产可见日志。</p> |
|||
* |
|||
* <p>本组件改用 {@link JdbcTemplate} 的参数化语句:</p> |
|||
* <ul> |
|||
* <li>冲突在 JDBC 层被捕获,**不经过** Hibernate 日志体系;</li> |
|||
* <li>原始数据库异常与 message/cause **一律丢弃**,对外只返回结果码;</li> |
|||
* <li>{@code users} 与 {@code user_roles} 在**同一事务**内提交 —— |
|||
* 角色写入失败时用户创建整体回滚;</li> |
|||
* <li>不打印 SQL、手机号或绑定参数。</li> |
|||
* </ul> |
|||
*/ |
|||
@Component |
|||
public class AdminBootstrapCreator { |
|||
|
|||
/** 创建结果:成功创建 / 数据库唯一约束冲突。 */ |
|||
public enum CreateResult { |
|||
CREATED, |
|||
CONFLICT |
|||
} |
|||
|
|||
/** 引导账号的最小状态视图(不含任何敏感字段输出)。 */ |
|||
public record BootstrapUser(Long id, String role, boolean active, boolean verified, |
|||
boolean passwordAbsent, List<String> roles) { |
|||
} |
|||
|
|||
private final JdbcTemplate jdbcTemplate; |
|||
private final TransactionTemplate transactionTemplate; |
|||
|
|||
/** |
|||
* 使用**独立 REQUIRES_NEW 事务**:冲突必须只回滚本次创建,不得把调用方的外层事务 |
|||
* 标记为 rollback-only(否则调用方提交时会抛 {@code UnexpectedRollbackException})。 |
|||
*/ |
|||
public AdminBootstrapCreator(JdbcTemplate jdbcTemplate, |
|||
org.springframework.transaction.PlatformTransactionManager transactionManager) { |
|||
this.jdbcTemplate = jdbcTemplate; |
|||
this.transactionTemplate = new TransactionTemplate(transactionManager); |
|||
this.transactionTemplate.setPropagationBehavior( |
|||
org.springframework.transaction.TransactionDefinition.PROPAGATION_REQUIRES_NEW); |
|||
} |
|||
|
|||
/** |
|||
* 在同一事务内写入 {@code users} 与 {@code user_roles}。 |
|||
* |
|||
* @return {@link CreateResult#CREATED} 或 {@link CreateResult#CONFLICT}; |
|||
* 冲突时不抛出任何携带原始数据库信息的异常 |
|||
*/ |
|||
public CreateResult createAdmin(String phone, String nickname) { |
|||
try { |
|||
transactionTemplate.executeWithoutResult(status -> { |
|||
jdbcTemplate.update( |
|||
"INSERT INTO users (phone, nickname, password_hash, role, is_active, is_verified, " |
|||
+ "created_at, updated_at) VALUES (?, ?, NULL, 'ADMIN', 1, 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)", |
|||
phone, nickname); |
|||
Long userId = jdbcTemplate.queryForObject( |
|||
"SELECT id FROM users WHERE phone = ?", Long.class, phone); |
|||
jdbcTemplate.update("INSERT INTO user_roles (user_id, role) VALUES (?, 'ADMIN')", userId); |
|||
}); |
|||
return CreateResult.CREATED; |
|||
} catch (DataAccessException e) { |
|||
// 唯一约束冲突(并发初始化)、角色写入失败或任何 JDBC 层失败:事务已整体回滚。
|
|||
// 刻意**丢弃**原始异常:它的 message 可能包含 SQL 与绑定值(例如手机号)。
|
|||
// 调用方据此重新查询,决定是「幂等成功」还是抛出不带 cause 的固定安全异常。
|
|||
return CreateResult.CONFLICT; |
|||
} |
|||
} |
|||
|
|||
/** 按手机号读取引导所需的最小状态;只读、参数化,不经过 Hibernate。 */ |
|||
public Optional<BootstrapUser> findByPhone(String phone) { |
|||
List<BootstrapUser> rows = jdbcTemplate.query( |
|||
"SELECT id, role, is_active, is_verified, password_hash FROM users WHERE phone = ?", |
|||
(rs, i) -> new BootstrapUser( |
|||
rs.getLong("id"), |
|||
rs.getString("role"), |
|||
rs.getInt("is_active") == 1, |
|||
rs.getInt("is_verified") == 1, |
|||
rs.getString("password_hash") == null, |
|||
List.of()), |
|||
phone); |
|||
if (rows.isEmpty()) { |
|||
return Optional.empty(); |
|||
} |
|||
BootstrapUser base = rows.get(0); |
|||
List<String> roles = jdbcTemplate.queryForList( |
|||
"SELECT role FROM user_roles WHERE user_id = ?", String.class, base.id()); |
|||
return Optional.of(new BootstrapUser(base.id(), base.role(), base.active(), |
|||
base.verified(), base.passwordAbsent(), roles)); |
|||
} |
|||
|
|||
/** 是否存在活动 ADMIN(不涉及手机号,可用 JPA 仓储)。 */ |
|||
} |
|||
@ -0,0 +1,117 @@ |
|||
package com.mydreamjob.auth; |
|||
|
|||
import com.mydreamjob.config.AdminBootstrapProperties; |
|||
import lombok.RequiredArgsConstructor; |
|||
import lombok.extern.slf4j.Slf4j; |
|||
import org.springframework.boot.ApplicationArguments; |
|||
import org.springframework.boot.ApplicationRunner; |
|||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; |
|||
import org.springframework.stereotype.Component; |
|||
|
|||
import java.util.Optional; |
|||
import java.util.Set; |
|||
import java.util.regex.Pattern; |
|||
|
|||
/** |
|||
* S03-A 一次性管理员初始化(显式开关,默认关闭)。 |
|||
* |
|||
* <p>仅在 {@code app.bootstrap.admin.enabled} 为字面量 {@code "true"} 时装配; |
|||
* 本类还会再校验一次字面量,确保大小写变体不会误启用。</p> |
|||
* |
|||
* <p>规则(设计 §3.2):</p> |
|||
* <ol> |
|||
* <li>手机号必须通过既有手机号格式校验;不写日志、异常详情或审计 details;</li> |
|||
* <li>已有活动 ADMIN → 无副作用跳过;</li> |
|||
* <li>无活动 ADMIN 且手机号未被占用 → 创建 active=true / verified=true 的 ADMIN, |
|||
* 同时写入 {@code users.role} 与 {@code user_roles};</li> |
|||
* <li>手机号已属于任何用户(任意角色 / inactive / 角色不一致)→ 启动失败, |
|||
* 禁止提权、复活或覆盖;</li> |
|||
* <li>相同配置重复启动幂等;</li> |
|||
* <li>多实例并发:数据库唯一手机号约束是最后防线,冲突直接失败,不吞掉。</li> |
|||
* </ol> |
|||
* |
|||
* <p>不签发 Token、不发送短信、不设置默认密码、不提供公网接口。</p> |
|||
*/ |
|||
@Slf4j |
|||
@Component |
|||
@RequiredArgsConstructor |
|||
@ConditionalOnProperty(name = "app.bootstrap.admin.enabled", havingValue = "true") |
|||
public class AdminBootstrapRunner implements ApplicationRunner { |
|||
|
|||
/** 与登录链路一致的手机号格式:11 位数字。 */ |
|||
private static final Pattern PHONE_PATTERN = Pattern.compile("[0-9]{11}"); |
|||
|
|||
/** 严格字面量:只有 "true" 才算启用。 */ |
|||
private static final String STRICTLY_ENABLED = "true"; |
|||
|
|||
/** 固定安全文案:不包含手机号、用户对象、SQL 或环境变量值。 */ |
|||
static final String ERROR_INVALID_PHONE = "admin bootstrap rejected: configured phone is invalid"; |
|||
static final String ERROR_PHONE_IN_USE = "admin bootstrap rejected: configured phone is already in use"; |
|||
|
|||
private final AdminBootstrapProperties properties; |
|||
private final UserRepository userRepository; |
|||
/** S03 整改:创建走独立的 JdbcTemplate 事务组件,避免 Hibernate 记录绑定手机号。 */ |
|||
private final AdminBootstrapCreator creator; |
|||
|
|||
@Override |
|||
public void run(ApplicationArguments args) { |
|||
// 兜底的字面量校验:即使 @ConditionalOnProperty 的匹配更宽松,也不允许非 "true" 生效。
|
|||
if (!STRICTLY_ENABLED.equals(properties.getEnabled())) { |
|||
return; |
|||
} |
|||
|
|||
String phone = properties.getPhone(); |
|||
if (phone == null || !PHONE_PATTERN.matcher(phone.trim()).matches()) { |
|||
throw new IllegalStateException(ERROR_INVALID_PHONE); |
|||
} |
|||
phone = phone.trim(); |
|||
|
|||
// 已有活动 ADMIN:无副作用跳过,绝不创建第二个管理员。
|
|||
if (userRepository.countByRoleAndActiveTrue(User.Role.ADMIN) > 0) { |
|||
log.info("admin bootstrap skipped: an active administrator already exists"); |
|||
return; |
|||
} |
|||
|
|||
// 手机号已被任何用户占用(含 inactive、STUDENT、COMPANY、角色不一致)?
|
|||
Optional<AdminBootstrapCreator.BootstrapUser> existing = creator.findByPhone(phone); |
|||
if (existing.isPresent()) { |
|||
// 方案 A:仅当现存账号正是本次初始化配置要求的合法 ADMIN 时幂等成功;
|
|||
// 普通用户 / inactive / 角色不一致一律启动失败,绝不提权、复活或覆盖。
|
|||
if (isValidBootstrapAdmin(existing.get())) { |
|||
log.info("admin bootstrap skipped: a matching administrator already exists"); |
|||
return; |
|||
} |
|||
throw new IllegalStateException(ERROR_PHONE_IN_USE); |
|||
} |
|||
|
|||
AdminBootstrapCreator.CreateResult result = creator.createAdmin(phone, properties.getNickname()); |
|||
if (result == AdminBootstrapCreator.CreateResult.CONFLICT) { |
|||
// 并发初始化:另一个实例刚创建了同一手机号。按方案 A 重新判定:
|
|||
// 只有现存账号完全合法才幂等成功,否则一律失败。
|
|||
if (creator.findByPhone(phone).filter(this::isValidBootstrapAdmin).isPresent()) { |
|||
log.info("admin bootstrap skipped: a matching administrator already exists"); |
|||
return; |
|||
} |
|||
throw new IllegalStateException(ERROR_PHONE_IN_USE); |
|||
} |
|||
|
|||
// 固定事件文本,不含手机号;部署方初始化后必须移除开关。
|
|||
log.info("admin bootstrap created an administrator; remove the bootstrap switch now"); |
|||
} |
|||
|
|||
/** |
|||
* 方案 A 的幂等判据:现存账号必须**恰好**是本次初始化所要求的合法管理员。 |
|||
* |
|||
* <p>要求角色为 ADMIN、active、verified、无默认口令、{@code user_roles} 含 ADMIN, |
|||
* 且不含 STUDENT/COMPANY 等附加角色。任一不满足即视为「手机号被占用」,启动失败。</p> |
|||
*/ |
|||
private boolean isValidBootstrapAdmin(AdminBootstrapCreator.BootstrapUser user) { |
|||
return User.Role.ADMIN.name().equals(user.role()) |
|||
&& user.active() |
|||
&& user.verified() |
|||
&& user.passwordAbsent() |
|||
&& user.roles().contains(User.Role.ADMIN.name()) |
|||
&& !user.roles().contains(User.Role.STUDENT.name()) |
|||
&& !user.roles().contains(User.Role.COMPANY.name()); |
|||
} |
|||
} |
|||
@ -0,0 +1,28 @@ |
|||
package com.mydreamjob.config; |
|||
|
|||
import lombok.Getter; |
|||
import lombok.Setter; |
|||
import org.springframework.boot.context.properties.ConfigurationProperties; |
|||
import org.springframework.stereotype.Component; |
|||
|
|||
/** |
|||
* S03-A 一次性管理员初始化配置。 |
|||
* |
|||
* <p>默认关闭;只有显式把开关设为字面量 {@code "true"} 才会装配初始化器。 |
|||
* 手机号只能来自环境变量,**没有默认值**,因此不会误建管理员。</p> |
|||
*/ |
|||
@Getter |
|||
@Setter |
|||
@Component |
|||
@ConfigurationProperties(prefix = "app.bootstrap.admin") |
|||
public class AdminBootstrapProperties { |
|||
|
|||
/** 是否启用一次性管理员初始化(严格 true 才生效)。 */ |
|||
private String enabled = "false"; |
|||
|
|||
/** 管理员手机号;无默认值,必须由部署方显式提供。 */ |
|||
private String phone = ""; |
|||
|
|||
/** 管理员昵称。 */ |
|||
private String nickname = "平台管理员"; |
|||
} |
|||
@ -0,0 +1,40 @@ |
|||
-- S03-A:禁用仍保持 V1_1 原始指纹的 Flyway 演示账号 |
|||
-- |
|||
-- 背景:V1_1__seed_data.sql 无环境隔离地插入了 ADMIN / STUDENT / COMPANY 三个演示账号, |
|||
-- 并带有公开的 bcrypt 口令哈希。这些账号不能在生产继续可用。 |
|||
-- |
|||
-- 原则: |
|||
-- * 只将 users.is_active 由 1 置为 0;不删除用户、角色、审计或任何业务数据,不重置自增; |
|||
-- * 不依赖固定 id(不使用 id = 1/2/3); |
|||
-- * 每条 UPDATE 同时匹配 V1_1 中的**原始手机号 + 原始邮箱 + 原始公开 bcrypt 哈希 + |
|||
-- 原始主角色 + is_active = 1**;任一指纹被运营人员改过的账号都不会命中,因而保持不受影响; |
|||
-- * 幂等:命中行被置为 is_active = 0 后,再次执行不会再匹配(条件含 is_active = 1)。 |
|||
-- |
|||
-- 认证签发链已拒绝 inactive 用户,因此被禁用的账号无法再取得会话。 |
|||
|
|||
-- ADMIN |
|||
UPDATE users |
|||
SET is_active = 0 |
|||
WHERE phone = '13800000001' |
|||
AND email = 'admin@mydreamjob.com' |
|||
AND password_hash = '$2a$10$N.zmdr9k7uOCQb376NoUnuTJ8iAt6Z5EHsM8lE9lBOsl7iKTVKIUi' |
|||
AND role = 'ADMIN' |
|||
AND is_active = 1; |
|||
|
|||
-- STUDENT |
|||
UPDATE users |
|||
SET is_active = 0 |
|||
WHERE phone = '13800000002' |
|||
AND email = 'student@mydreamjob.com' |
|||
AND password_hash = '$2a$10$N.zmdr9k7uOCQb376NoUnuTJ8iAt6Z5EHsM8lE9lBOsl7iKTVKIUi' |
|||
AND role = 'STUDENT' |
|||
AND is_active = 1; |
|||
|
|||
-- COMPANY |
|||
UPDATE users |
|||
SET is_active = 0 |
|||
WHERE phone = '13800000003' |
|||
AND email = 'company@mydreamjob.com' |
|||
AND password_hash = '$2a$10$N.zmdr9k7uOCQb376NoUnuTJ8iAt6Z5EHsM8lE9lBOsl7iKTVKIUi' |
|||
AND role = 'COMPANY' |
|||
AND is_active = 1; |
|||
@ -0,0 +1,320 @@ |
|||
package com.mydreamjob.application; |
|||
|
|||
import com.mydreamjob.common.exception.BusinessException; |
|||
import com.mydreamjob.common.exception.ErrorCode; |
|||
import com.mydreamjob.company.Company; |
|||
import com.mydreamjob.company.CompanyRepository; |
|||
import com.mydreamjob.job.Job; |
|||
import com.mydreamjob.job.JobRepository; |
|||
import com.mydreamjob.privacy.ResumeVisibility; |
|||
import com.mydreamjob.privacy.StudentPrivacySettings; |
|||
import com.mydreamjob.privacy.StudentPrivacySettingsRepository; |
|||
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 org.mockito.junit.jupiter.MockitoSettings; |
|||
import org.mockito.quality.Strictness; |
|||
|
|||
import java.util.List; |
|||
import java.util.Optional; |
|||
|
|||
import static org.junit.jupiter.api.Assertions.*; |
|||
import static org.mockito.ArgumentMatchers.anyLong; |
|||
import static org.mockito.Mockito.when; |
|||
|
|||
/** |
|||
* S03-B 企业简历授权决策矩阵(设计 §4.3)。 |
|||
* |
|||
* <p>真实策略 + Mock Repository;覆盖三种可见性 × 全部投递状态 × 公司归属 × 跨企业 × 未认证, |
|||
* 以及下载入口的拒绝语义与错误码。</p> |
|||
*/ |
|||
@ExtendWith(MockitoExtension.class) |
|||
// 决策矩阵用例会一次性准备好「企业 + 岗位 + 投递 + 隐私」的完整上下文,
|
|||
// 而拒绝分支会提前返回、不消费全部 stub —— 这是刻意覆盖矩阵的必然结果,
|
|||
// 因此关闭「未使用 stub」的严格校验,断言本身仍逐条生效。
|
|||
@MockitoSettings(strictness = Strictness.LENIENT) |
|||
class CompanyResumeAccessPolicyTest { |
|||
|
|||
private static final Long STUDENT_ID = 100L; |
|||
private static final Long COMPANY_ID = 200L; |
|||
private static final Long OTHER_COMPANY_ID = 201L; |
|||
private static final Long COMPANY_USER_ID = 300L; |
|||
private static final Long APP_ID = 400L; |
|||
private static final Long JOB_ID = 500L; |
|||
|
|||
private static final List<String> ALL_STATUSES = List.of( |
|||
"SUBMITTED", "VIEWED", "INTERVIEW", "HIRED", "REJECTED", "WITHDRAWN"); |
|||
private static final List<String> EFFECTIVE = List.of("SUBMITTED", "VIEWED", "INTERVIEW", "HIRED"); |
|||
private static final List<String> INEFFECTIVE = List.of("REJECTED", "WITHDRAWN"); |
|||
|
|||
@Mock private CompanyRepository companyRepository; |
|||
@Mock private ApplicationRepository applicationRepository; |
|||
@Mock private JobRepository jobRepository; |
|||
@Mock private StudentPrivacySettingsRepository privacyRepository; |
|||
|
|||
@InjectMocks private CompanyResumeAccessPolicy policy; |
|||
|
|||
// ── 工具 ────────────────────────────────────────────────────
|
|||
|
|||
private void givenApplication(String status) { |
|||
when(applicationRepository.findById(APP_ID)).thenReturn(Optional.of( |
|||
Application.builder().id(APP_ID).jobId(JOB_ID).studentUserId(STUDENT_ID).status(status).build())); |
|||
} |
|||
|
|||
private void givenJobOwnedBy(Long companyId) { |
|||
when(jobRepository.findById(JOB_ID)).thenReturn(Optional.of( |
|||
Job.builder().id(JOB_ID).companyId(companyId).build())); |
|||
} |
|||
|
|||
private void givenVisibility(ResumeVisibility visibility) { |
|||
when(privacyRepository.findByStudentUserId(STUDENT_ID)).thenReturn(Optional.of( |
|||
StudentPrivacySettings.builder().studentUserId(STUDENT_ID) |
|||
.resumeVisibility(visibility).contactVisible(false).version(0L).build())); |
|||
} |
|||
|
|||
private void givenCompany(Long companyId, boolean verified) { |
|||
when(companyRepository.findById(companyId)).thenReturn(Optional.of( |
|||
Company.builder().id(companyId).verified(verified).build())); |
|||
} |
|||
|
|||
// ── 参数与记录缺失:一律拒绝 ────────────────────────────────
|
|||
|
|||
@Test |
|||
void nullArgumentsAreRejected() { |
|||
assertFalse(policy.isVisible(null, COMPANY_ID, APP_ID)); |
|||
assertFalse(policy.isVisible(STUDENT_ID, null, APP_ID)); |
|||
assertFalse(policy.isVisible(STUDENT_ID, COMPANY_ID, null)); |
|||
} |
|||
|
|||
@Test |
|||
void missingApplicationIsRejected() { |
|||
when(applicationRepository.findById(APP_ID)).thenReturn(Optional.empty()); |
|||
assertFalse(policy.isVisible(STUDENT_ID, COMPANY_ID, APP_ID)); |
|||
} |
|||
|
|||
@Test |
|||
void studentMismatchIsRejected() { |
|||
when(applicationRepository.findById(APP_ID)).thenReturn(Optional.of( |
|||
Application.builder().id(APP_ID).jobId(JOB_ID).studentUserId(999L).status("SUBMITTED").build())); |
|||
assertFalse(policy.isVisible(STUDENT_ID, COMPANY_ID, APP_ID)); |
|||
} |
|||
|
|||
@Test |
|||
void missingJobIsRejected() { |
|||
givenApplication("SUBMITTED"); |
|||
when(jobRepository.findById(JOB_ID)).thenReturn(Optional.empty()); |
|||
assertFalse(policy.isVisible(STUDENT_ID, COMPANY_ID, APP_ID)); |
|||
} |
|||
|
|||
// ── PRIVATE / 无记录 ───────────────────────────────────────
|
|||
|
|||
@Test |
|||
void privateIsRejectedForEveryStatusEvenWhenCompanyVerified() { |
|||
for (String status : ALL_STATUSES) { |
|||
givenApplication(status); |
|||
givenJobOwnedBy(COMPANY_ID); |
|||
givenVisibility(ResumeVisibility.PRIVATE); |
|||
givenCompany(COMPANY_ID, true); |
|||
assertFalse(policy.isVisible(STUDENT_ID, COMPANY_ID, APP_ID), "PRIVATE 必须拒绝:" + status); |
|||
} |
|||
} |
|||
|
|||
@Test |
|||
void noSettingsRecordIsTreatedAsPrivate() { |
|||
for (String status : ALL_STATUSES) { |
|||
givenApplication(status); |
|||
givenJobOwnedBy(COMPANY_ID); |
|||
when(privacyRepository.findByStudentUserId(STUDENT_ID)).thenReturn(Optional.empty()); |
|||
assertFalse(policy.isVisible(STUDENT_ID, COMPANY_ID, APP_ID), "无记录必须按 PRIVATE 拒绝:" + status); |
|||
} |
|||
} |
|||
|
|||
@Test |
|||
void nullVisibilityIsTreatedAsPrivate() { |
|||
givenApplication("SUBMITTED"); |
|||
givenJobOwnedBy(COMPANY_ID); |
|||
when(privacyRepository.findByStudentUserId(STUDENT_ID)).thenReturn(Optional.of( |
|||
StudentPrivacySettings.builder().studentUserId(STUDENT_ID) |
|||
.resumeVisibility(null).contactVisible(false).version(0L).build())); |
|||
assertFalse(policy.isVisible(STUDENT_ID, COMPANY_ID, APP_ID)); |
|||
} |
|||
|
|||
// ── APPLIED_COMPANIES ──────────────────────────────────────
|
|||
|
|||
@Test |
|||
void appliedCompaniesAllowsOnlyEffectiveStatusesWhenCompanyOwnsJob() { |
|||
givenJobOwnedBy(COMPANY_ID); |
|||
givenVisibility(ResumeVisibility.APPLIED_COMPANIES); |
|||
for (String status : ALL_STATUSES) { |
|||
givenApplication(status); |
|||
boolean expected = EFFECTIVE.contains(status); |
|||
assertEquals(expected, policy.isVisible(STUDENT_ID, COMPANY_ID, APP_ID), |
|||
"APPLIED_COMPANIES 状态 " + status + " 判定不符"); |
|||
} |
|||
} |
|||
|
|||
// ── APPLIED_COMPANIES 必须是白名单(失败关闭)──────────────
|
|||
|
|||
@Test |
|||
void appliedCompaniesAllowsExactlyFourReviewedStatuses() { |
|||
givenJobOwnedBy(COMPANY_ID); |
|||
givenVisibility(ResumeVisibility.APPLIED_COMPANIES); |
|||
|
|||
for (String status : ALL_STATUSES) { |
|||
givenApplication(status); |
|||
boolean expected = EFFECTIVE.contains(status); |
|||
assertEquals(expected, policy.isVisible(STUDENT_ID, COMPANY_ID, APP_ID), |
|||
"只有四个已审阅状态可放行:" + status); |
|||
} |
|||
} |
|||
|
|||
@Test |
|||
void appliedCompaniesRejectsNullStatus() { |
|||
givenJobOwnedBy(COMPANY_ID); |
|||
givenVisibility(ResumeVisibility.APPLIED_COMPANIES); |
|||
givenApplication(null); |
|||
|
|||
assertFalse(policy.isVisible(STUDENT_ID, COMPANY_ID, APP_ID), "null 状态必须拒绝"); |
|||
} |
|||
|
|||
@Test |
|||
void appliedCompaniesRejectsBlankStatus() { |
|||
givenJobOwnedBy(COMPANY_ID); |
|||
givenVisibility(ResumeVisibility.APPLIED_COMPANIES); |
|||
for (String blank : new String[]{"", " ", " ", "\t"}) { |
|||
givenApplication(blank); |
|||
assertFalse(policy.isVisible(STUDENT_ID, COMPANY_ID, APP_ID), "空白状态必须拒绝:'" + blank + "'"); |
|||
} |
|||
} |
|||
|
|||
@Test |
|||
void appliedCompaniesRejectsUnknownStatus() { |
|||
givenJobOwnedBy(COMPANY_ID); |
|||
givenVisibility(ResumeVisibility.APPLIED_COMPANIES); |
|||
for (String unknown : new String[]{"PENDING", "DRAFT", "ARCHIVED", "DELETED", "REVIEWING"}) { |
|||
givenApplication(unknown); |
|||
assertFalse(policy.isVisible(STUDENT_ID, COMPANY_ID, APP_ID), "未知状态必须拒绝:" + unknown); |
|||
} |
|||
} |
|||
|
|||
@Test |
|||
void appliedCompaniesRejectsLowercaseStatus() { |
|||
givenJobOwnedBy(COMPANY_ID); |
|||
givenVisibility(ResumeVisibility.APPLIED_COMPANIES); |
|||
for (String lower : new String[]{"submitted", "viewed", "interview", "hired"}) { |
|||
givenApplication(lower); |
|||
assertFalse(policy.isVisible(STUDENT_ID, COMPANY_ID, APP_ID), |
|||
"大小写变体必须拒绝(不得宽松匹配):" + lower); |
|||
} |
|||
} |
|||
|
|||
@Test |
|||
void appliedCompaniesRejectsOtherCompanyEvenWithEffectiveApplication() { |
|||
givenJobOwnedBy(OTHER_COMPANY_ID); |
|||
givenVisibility(ResumeVisibility.APPLIED_COMPANIES); |
|||
for (String status : EFFECTIVE) { |
|||
givenApplication(status); |
|||
assertFalse(policy.isVisible(STUDENT_ID, COMPANY_ID, APP_ID), |
|||
"跨企业必须拒绝(投递属于其他公司的岗位):" + status); |
|||
} |
|||
} |
|||
|
|||
// ── VERIFIED_COMPANIES ─────────────────────────────────────
|
|||
|
|||
@Test |
|||
void verifiedCompaniesAllowsAnyStatusWhenVerifiedAndOwnsJob() { |
|||
givenJobOwnedBy(COMPANY_ID); |
|||
givenVisibility(ResumeVisibility.VERIFIED_COMPANIES); |
|||
givenCompany(COMPANY_ID, true); |
|||
for (String status : ALL_STATUSES) { |
|||
givenApplication(status); |
|||
assertTrue(policy.isVisible(STUDENT_ID, COMPANY_ID, APP_ID), |
|||
"VERIFIED_COMPANIES 已认证且拥有岗位时状态不额外限制:" + status); |
|||
} |
|||
} |
|||
|
|||
@Test |
|||
void verifiedCompaniesRejectsUnverifiedCompany() { |
|||
givenJobOwnedBy(COMPANY_ID); |
|||
givenVisibility(ResumeVisibility.VERIFIED_COMPANIES); |
|||
givenCompany(COMPANY_ID, false); |
|||
assertFalse(policy.isVisible(STUDENT_ID, COMPANY_ID, APP_ID)); |
|||
} |
|||
|
|||
@Test |
|||
void verifiedCompaniesRejectsUnknownCompany() { |
|||
givenJobOwnedBy(COMPANY_ID); |
|||
givenVisibility(ResumeVisibility.VERIFIED_COMPANIES); |
|||
when(companyRepository.findById(COMPANY_ID)).thenReturn(Optional.empty()); |
|||
assertFalse(policy.isVisible(STUDENT_ID, COMPANY_ID, APP_ID)); |
|||
} |
|||
|
|||
@Test |
|||
void verifiedCompaniesRejectsWhenCompanyDoesNotOwnJob() { |
|||
givenJobOwnedBy(OTHER_COMPANY_ID); |
|||
givenVisibility(ResumeVisibility.VERIFIED_COMPANIES); |
|||
givenCompany(COMPANY_ID, true); |
|||
for (String status : ALL_STATUSES) { |
|||
givenApplication(status); |
|||
assertFalse(policy.isVisible(STUDENT_ID, COMPANY_ID, APP_ID), |
|||
"已验证企业也必须拥有该投递岗位:" + status); |
|||
} |
|||
} |
|||
|
|||
// ── 下载入口的拒绝语义 ────────────────────────────────────
|
|||
|
|||
@Test |
|||
void authorizeForDownloadRejectsUserWithoutCompany() { |
|||
when(companyRepository.findByOwnerUserId(COMPANY_USER_ID)).thenReturn(Optional.empty()); |
|||
BusinessException ex = assertThrows(BusinessException.class, |
|||
() -> policy.authorizeForDownload(COMPANY_USER_ID, APP_ID)); |
|||
assertEquals(ErrorCode.FORBIDDEN.getCode(), ex.getErrorCodeInt()); |
|||
} |
|||
|
|||
@Test |
|||
void authorizeForDownloadReturnsNotFoundForMissingApplication() { |
|||
when(companyRepository.findByOwnerUserId(COMPANY_USER_ID)).thenReturn(Optional.of( |
|||
Company.builder().id(COMPANY_ID).verified(true).build())); |
|||
when(applicationRepository.findById(APP_ID)).thenReturn(Optional.empty()); |
|||
|
|||
BusinessException ex = assertThrows(BusinessException.class, |
|||
() -> policy.authorizeForDownload(COMPANY_USER_ID, APP_ID)); |
|||
assertEquals(ErrorCode.APPLICATION_NOT_FOUND.getCode(), ex.getErrorCodeInt()); |
|||
} |
|||
|
|||
@Test |
|||
void authorizeForDownloadRejectsCrossCompanyWithForbidden() { |
|||
when(companyRepository.findByOwnerUserId(COMPANY_USER_ID)).thenReturn(Optional.of( |
|||
Company.builder().id(COMPANY_ID).verified(true).build())); |
|||
givenApplication("SUBMITTED"); |
|||
givenJobOwnedBy(OTHER_COMPANY_ID); |
|||
givenVisibility(ResumeVisibility.APPLIED_COMPANIES); |
|||
|
|||
BusinessException ex = assertThrows(BusinessException.class, |
|||
() -> policy.authorizeForDownload(COMPANY_USER_ID, APP_ID)); |
|||
assertEquals(ErrorCode.FORBIDDEN.getCode(), ex.getErrorCodeInt()); |
|||
} |
|||
|
|||
@Test |
|||
void authorizeForDownloadReturnsStudentIdWhenAllowed() { |
|||
when(companyRepository.findByOwnerUserId(COMPANY_USER_ID)).thenReturn(Optional.of( |
|||
Company.builder().id(COMPANY_ID).verified(false).build())); |
|||
givenApplication("SUBMITTED"); |
|||
givenJobOwnedBy(COMPANY_ID); |
|||
givenVisibility(ResumeVisibility.APPLIED_COMPANIES); |
|||
|
|||
assertEquals(STUDENT_ID, policy.authorizeForDownload(COMPANY_USER_ID, APP_ID).studentUserId()); |
|||
} |
|||
|
|||
@Test |
|||
void authorizeForDownloadRejectsNullArguments() { |
|||
assertEquals(ErrorCode.FORBIDDEN.getCode(), |
|||
assertThrows(BusinessException.class, |
|||
() -> policy.authorizeForDownload(null, APP_ID)).getErrorCodeInt()); |
|||
assertEquals(ErrorCode.FORBIDDEN.getCode(), |
|||
assertThrows(BusinessException.class, |
|||
() -> policy.authorizeForDownload(COMPANY_USER_ID, null)).getErrorCodeInt()); |
|||
} |
|||
} |
|||
@ -0,0 +1,606 @@ |
|||
package com.mydreamjob.application; |
|||
|
|||
import com.mydreamjob.auth.User; |
|||
import com.mydreamjob.auth.UserRepository; |
|||
import com.mydreamjob.auth.support.TestAuthTokens; |
|||
import com.mydreamjob.company.Company; |
|||
import com.mydreamjob.company.CompanyRepository; |
|||
import com.mydreamjob.job.Job; |
|||
import com.mydreamjob.job.JobRepository; |
|||
import com.mydreamjob.job.JobStatus; |
|||
import com.mydreamjob.privacy.ResumeVisibility; |
|||
import com.mydreamjob.privacy.StudentPrivacySettings; |
|||
import com.mydreamjob.privacy.StudentPrivacySettingsRepository; |
|||
import com.mydreamjob.profile.StudentProfile; |
|||
import com.mydreamjob.profile.StudentProfileRepository; |
|||
import com.mydreamjob.profile.storage.ResumeStorageService; |
|||
import org.junit.jupiter.api.AfterEach; |
|||
import org.junit.jupiter.api.BeforeEach; |
|||
import org.junit.jupiter.api.Test; |
|||
import org.springframework.beans.factory.annotation.Autowired; |
|||
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; |
|||
import org.springframework.boot.test.context.SpringBootTest; |
|||
import org.springframework.boot.test.context.TestConfiguration; |
|||
import org.springframework.boot.test.mock.mockito.MockBean; |
|||
import org.springframework.context.annotation.Bean; |
|||
import org.springframework.context.annotation.Primary; |
|||
import org.springframework.data.redis.core.RedisTemplate; |
|||
import org.springframework.data.redis.core.ValueOperations; |
|||
import org.springframework.mock.web.MockMultipartFile; |
|||
import org.springframework.test.context.ActiveProfiles; |
|||
import org.springframework.test.context.TestPropertySource; |
|||
import org.springframework.test.web.servlet.MockMvc; |
|||
|
|||
import java.nio.charset.StandardCharsets; |
|||
import java.util.List; |
|||
import java.util.Set; |
|||
import java.util.concurrent.atomic.AtomicInteger; |
|||
import java.util.concurrent.atomic.AtomicLong; |
|||
|
|||
import static org.assertj.core.api.Assertions.assertThat; |
|||
import static org.mockito.ArgumentMatchers.anyString; |
|||
import static org.mockito.Mockito.mock; |
|||
import static org.mockito.Mockito.when; |
|||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; |
|||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; |
|||
|
|||
/** |
|||
* S03-B 企业简历下载权限矩阵(设计 §4.3),真实 SecurityFilterChain + 真实 H2 + 真实本地存储。 |
|||
* |
|||
* <p>只用一个计数包装器包住存储实现,用于证明**拒绝路径零文件读取**; |
|||
* 授权链本身完全真实,未被 Mock。</p> |
|||
*/ |
|||
@SpringBootTest |
|||
@AutoConfigureMockMvc |
|||
@ActiveProfiles("integration-test") |
|||
@TestPropertySource(properties = "app.resume.storage-dir=target/s03-test-resumes") |
|||
class CompanyResumeDownloadIntegrationTest { |
|||
|
|||
private static final AtomicLong PHONE_SEQ = new AtomicLong(8800); |
|||
private static final List<String> ALL_STATUSES = |
|||
List.of("SUBMITTED", "VIEWED", "INTERVIEW", "HIRED", "REJECTED", "WITHDRAWN"); |
|||
private static final List<String> EFFECTIVE = List.of("SUBMITTED", "VIEWED", "INTERVIEW", "HIRED"); |
|||
|
|||
/** 统计 read() 调用次数,用于断言拒绝路径不触碰文件。 */ |
|||
static final AtomicInteger READ_CALLS = new AtomicInteger(); |
|||
|
|||
@TestConfiguration |
|||
static class CountingStorageConfig { |
|||
@Bean |
|||
@Primary |
|||
ResumeStorageService countingResumeStorageService( |
|||
com.mydreamjob.profile.storage.LocalResumeStorageService delegate) { |
|||
return new ResumeStorageService() { |
|||
@Override |
|||
public String store(Long userId, org.springframework.web.multipart.MultipartFile file) { |
|||
return delegate.store(userId, file); |
|||
} |
|||
|
|||
@Override |
|||
public byte[] read(String storagePath) { |
|||
READ_CALLS.incrementAndGet(); |
|||
return delegate.read(storagePath); |
|||
} |
|||
|
|||
@Override |
|||
public void delete(String storagePath) { |
|||
delegate.delete(storagePath); |
|||
} |
|||
}; |
|||
} |
|||
} |
|||
|
|||
@MockBean |
|||
private RedisTemplate<String, Object> redisTemplate; |
|||
|
|||
@Autowired private MockMvc mockMvc; |
|||
@Autowired private UserRepository userRepository; |
|||
@Autowired private CompanyRepository companyRepository; |
|||
@Autowired private JobRepository jobRepository; |
|||
@Autowired private ApplicationRepository applicationRepository; |
|||
@Autowired private StudentProfileRepository profileRepository; |
|||
@Autowired private StudentPrivacySettingsRepository privacyRepository; |
|||
@Autowired private ResumeStorageService resumeStorageService; |
|||
@Autowired private org.springframework.jdbc.core.JdbcTemplate jdbcTemplate; |
|||
|
|||
private Long companyUserId; |
|||
private Long otherCompanyUserId; |
|||
private Long companyId; |
|||
private Long otherCompanyId; |
|||
private Long studentUserId; |
|||
private Long adminUserId; |
|||
private Long jobId; |
|||
private Long applicationId; |
|||
private String storagePath; |
|||
|
|||
@BeforeEach |
|||
@SuppressWarnings("unchecked") |
|||
void setUp() { |
|||
READ_CALLS.set(0); |
|||
ValueOperations<String, Object> ops = mock(ValueOperations.class); |
|||
when(redisTemplate.opsForValue()).thenReturn(ops); |
|||
when(redisTemplate.hasKey(anyString())).thenReturn(false); |
|||
|
|||
companyUserId = createUser(User.Role.COMPANY, "S03 企业"); |
|||
otherCompanyUserId = createUser(User.Role.COMPANY, "S03 他企"); |
|||
studentUserId = createUser(User.Role.STUDENT, "S03 学生"); |
|||
adminUserId = createUser(User.Role.ADMIN, "S03 管理员"); |
|||
|
|||
companyId = companyRepository.save(Company.builder() |
|||
.ownerUserId(companyUserId).name("S03 甲企业").verified(false).build()).getId(); |
|||
otherCompanyId = companyRepository.save(Company.builder() |
|||
.ownerUserId(otherCompanyUserId).name("S03 乙企业").verified(true).build()).getId(); |
|||
|
|||
jobId = jobRepository.save(Job.builder() |
|||
.companyId(companyId).title("S03 岗位").description("d").city("杭州") |
|||
.employmentType("FULL_TIME").status(JobStatus.PUBLISHED.name()).build()).getId(); |
|||
|
|||
applicationId = applicationRepository.save(Application.builder() |
|||
.jobId(jobId).studentUserId(studentUserId).status("SUBMITTED").build()).getId(); |
|||
|
|||
// 真实写入一个 PDF 文件并挂到学生档案
|
|||
MockMultipartFile file = new MockMultipartFile("file", "resume.pdf", "application/pdf", |
|||
"%PDF-1.4 s03 content".getBytes(StandardCharsets.UTF_8)); |
|||
storagePath = resumeStorageService.store(studentUserId, file); |
|||
profileRepository.save(StudentProfile.builder() |
|||
.userId(studentUserId) |
|||
.resumeUrl(storagePath) |
|||
.resumeOriginalName("我的简历.pdf") |
|||
.resumeContentType("application/pdf") |
|||
.resumeSize(file.getSize()) |
|||
.build()); |
|||
} |
|||
|
|||
@AfterEach |
|||
void tearDown() { |
|||
if (storagePath != null) { |
|||
resumeStorageService.delete(storagePath); |
|||
} |
|||
profileRepository.findByUserId(studentUserId).ifPresent(profileRepository::delete); |
|||
privacyRepository.findByStudentUserId(studentUserId).ifPresent(privacyRepository::delete); |
|||
if (applicationId != null) applicationRepository.deleteById(applicationId); |
|||
if (jobId != null) jobRepository.deleteById(jobId); |
|||
if (companyId != null) companyRepository.deleteById(companyId); |
|||
if (otherCompanyId != null) companyRepository.deleteById(otherCompanyId); |
|||
List.of(companyUserId, otherCompanyUserId, studentUserId, adminUserId) |
|||
.forEach(id -> { if (id != null) userRepository.deleteById(id); }); |
|||
} |
|||
|
|||
private Long createUser(User.Role role, String nickname) { |
|||
return userRepository.save(User.builder() |
|||
.phone("17" + String.format("%09d", PHONE_SEQ.incrementAndGet())) |
|||
.nickname(nickname) |
|||
.role(role) |
|||
.roles(Set.of(role)) |
|||
.active(true) |
|||
.verified(true) |
|||
.build()).getId(); |
|||
} |
|||
|
|||
private void setVisibility(ResumeVisibility visibility) { |
|||
privacyRepository.findByStudentUserId(studentUserId) |
|||
.ifPresent(privacyRepository::delete); |
|||
if (visibility != null) { |
|||
privacyRepository.saveAndFlush(StudentPrivacySettings.builder() |
|||
.studentUserId(studentUserId).resumeVisibility(visibility) |
|||
.contactVisible(false).version(0L).build()); |
|||
} |
|||
} |
|||
|
|||
private void setStatus(String status) { |
|||
Application app = applicationRepository.findById(applicationId).orElseThrow(); |
|||
app.setStatus(status); |
|||
applicationRepository.saveAndFlush(app); |
|||
} |
|||
|
|||
private String url() { |
|||
return "/company/candidates/" + applicationId + "/resume"; |
|||
} |
|||
|
|||
private String companyToken() { |
|||
return TestAuthTokens.bearer(companyUserId, "COMPANY"); |
|||
} |
|||
|
|||
// ── 决策矩阵:3 种可见性 × 6 种状态 ─────────────────────────
|
|||
|
|||
@Test |
|||
void privateIsDeniedForEveryStatusAndNeverReadsFile() { |
|||
setVisibility(ResumeVisibility.PRIVATE); |
|||
for (String status : ALL_STATUSES) { |
|||
setStatus(status); |
|||
READ_CALLS.set(0); |
|||
int code = perform(companyToken()); |
|||
assertThat(code).as("PRIVATE 必须 403:" + status).isEqualTo(403); |
|||
assertThat(READ_CALLS.get()).as("拒绝路径不得读取文件:" + status).isZero(); |
|||
} |
|||
} |
|||
|
|||
@Test |
|||
void missingSettingsIsDeniedAsPrivate() { |
|||
setVisibility(null); |
|||
for (String status : ALL_STATUSES) { |
|||
setStatus(status); |
|||
READ_CALLS.set(0); |
|||
assertThat(perform(companyToken())).as("无设置按 PRIVATE 拒绝:" + status).isEqualTo(403); |
|||
assertThat(READ_CALLS.get()).isZero(); |
|||
} |
|||
} |
|||
|
|||
@Test |
|||
void appliedCompaniesAllowsOnlyEffectiveStatusesForOwningCompany() throws Exception { |
|||
setVisibility(ResumeVisibility.APPLIED_COMPANIES); |
|||
for (String status : ALL_STATUSES) { |
|||
setStatus(status); |
|||
READ_CALLS.set(0); |
|||
if (EFFECTIVE.contains(status)) { |
|||
mockMvc.perform(get(url()).header("Authorization", companyToken())) |
|||
.andExpect(status().isOk()) |
|||
.andExpect(header().string("Cache-Control", "no-store")) |
|||
.andExpect(content().contentType("application/pdf")); |
|||
assertThat(READ_CALLS.get()).as("授权通过应读取一次:" + status).isEqualTo(1); |
|||
} else { |
|||
assertThat(perform(companyToken())).as("WITHDRAWN/REJECTED 应 403:" + status).isEqualTo(403); |
|||
assertThat(READ_CALLS.get()).as("拒绝路径不得读取文件:" + status).isZero(); |
|||
} |
|||
} |
|||
} |
|||
|
|||
@Test |
|||
void verifiedCompaniesAllowsAnyStatusOnlyForVerifiedOwningCompany() { |
|||
// 甲企业未认证 → 全部拒绝
|
|||
setVisibility(ResumeVisibility.VERIFIED_COMPANIES); |
|||
for (String status : ALL_STATUSES) { |
|||
setStatus(status); |
|||
READ_CALLS.set(0); |
|||
assertThat(perform(companyToken())).as("未认证企业应 403:" + status).isEqualTo(403); |
|||
assertThat(READ_CALLS.get()).isZero(); |
|||
} |
|||
|
|||
// 认证后 → 全部状态允许
|
|||
Company company = companyRepository.findById(companyId).orElseThrow(); |
|||
company.setVerified(true); |
|||
companyRepository.saveAndFlush(company); |
|||
for (String status : ALL_STATUSES) { |
|||
setStatus(status); |
|||
READ_CALLS.set(0); |
|||
assertThat(perform(companyToken())).as("已认证且拥有岗位应 200:" + status).isEqualTo(200); |
|||
assertThat(READ_CALLS.get()).isEqualTo(1); |
|||
} |
|||
} |
|||
|
|||
// ── APPLIED_COMPANIES 白名单:退化状态必须 403 且零读取 ──────
|
|||
|
|||
@Test |
|||
void appliedCompaniesUnknownStatusReturns403WithoutReadingStorage() throws Exception { |
|||
setVisibility(ResumeVisibility.APPLIED_COMPANIES); |
|||
|
|||
for (String unknown : new String[]{"PENDING", "DRAFT", "ARCHIVED", "submitted"}) { |
|||
setStatus(unknown); |
|||
READ_CALLS.set(0); |
|||
|
|||
var result = mockMvc.perform(get(url()).header("Authorization", companyToken())) |
|||
.andReturn().getResponse(); |
|||
assertThat(result.getStatus()).as("未知/变体状态必须 403:" + unknown).isEqualTo(403); |
|||
assertThat(result.getContentAsString()).as("业务码必须 2002:" + unknown).contains("\"code\":2002"); |
|||
assertThat(READ_CALLS.get()).as("拒绝路径不得读取文件:" + unknown).isZero(); |
|||
} |
|||
} |
|||
|
|||
@Test |
|||
void appliedCompaniesBlankStatusReturns403WithoutReadingStorage() throws Exception { |
|||
// 说明:applications.status 在 V2 中是 NOT NULL,因此 DB 层无法保存 NULL;
|
|||
// NULL 情形由 CompanyResumeAccessPolicyTest 在内存中覆盖,这里用可达的空白值验证。
|
|||
setVisibility(ResumeVisibility.APPLIED_COMPANIES); |
|||
|
|||
for (String blank : new String[]{"", " "}) { |
|||
setStatus(blank); |
|||
READ_CALLS.set(0); |
|||
|
|||
var result = mockMvc.perform(get(url()).header("Authorization", companyToken())) |
|||
.andReturn().getResponse(); |
|||
assertThat(result.getStatus()).as("空白状态必须 403").isEqualTo(403); |
|||
assertThat(result.getContentAsString()).as("业务码必须 2002").contains("\"code\":2002"); |
|||
assertThat(READ_CALLS.get()).as("拒绝路径不得读取文件").isZero(); |
|||
} |
|||
} |
|||
|
|||
// ── 跨企业 ─────────────────────────────────────────────────
|
|||
|
|||
@Test |
|||
void otherCompanyIsAlwaysDenied() { |
|||
setVisibility(ResumeVisibility.APPLIED_COMPANIES); |
|||
READ_CALLS.set(0); |
|||
assertThat(perform(TestAuthTokens.bearer(otherCompanyUserId, "COMPANY"))) |
|||
.as("跨企业必须 403").isEqualTo(403); |
|||
assertThat(READ_CALLS.get()).isZero(); |
|||
|
|||
setVisibility(ResumeVisibility.VERIFIED_COMPANIES); |
|||
READ_CALLS.set(0); |
|||
assertThat(perform(TestAuthTokens.bearer(otherCompanyUserId, "COMPANY"))) |
|||
.as("其他企业即使已认证也必须 403").isEqualTo(403); |
|||
assertThat(READ_CALLS.get()).isZero(); |
|||
} |
|||
|
|||
// ── 角色与非认证 ───────────────────────────────────────────
|
|||
|
|||
@Test |
|||
void studentAdminAndAnonymousAreDenied() { |
|||
READ_CALLS.set(0); |
|||
setVisibility(ResumeVisibility.VERIFIED_COMPANIES); |
|||
Company company = companyRepository.findById(companyId).orElseThrow(); |
|||
company.setVerified(true); |
|||
companyRepository.saveAndFlush(company); |
|||
|
|||
assertThat(perform(TestAuthTokens.bearer(studentUserId, "STUDENT"))).as("STUDENT 应 403").isEqualTo(403); |
|||
assertThat(perform(TestAuthTokens.bearer(adminUserId, "ADMIN"))).as("ADMIN 应 403").isEqualTo(403); |
|||
assertThat(performNoAuth()).as("匿名应 401").isEqualTo(401); |
|||
assertThat(READ_CALLS.get()).as("拒绝路径不得读取文件").isZero(); |
|||
} |
|||
|
|||
@Test |
|||
void nonexistentApplicationIsNotFound() { |
|||
setVisibility(ResumeVisibility.APPLIED_COMPANIES); |
|||
READ_CALLS.set(0); |
|||
String token = companyToken(); |
|||
int code; |
|||
try { |
|||
code = mockMvc.perform(get("/company/candidates/99999999/resume").header("Authorization", token)) |
|||
.andReturn().getResponse().getStatus(); |
|||
} catch (Exception e) { |
|||
code = -1; |
|||
} |
|||
assertThat(code).as("不存在的投递应 404").isEqualTo(404); |
|||
assertThat(READ_CALLS.get()).isZero(); |
|||
} |
|||
|
|||
@Test |
|||
void missingResumeIsProfileNotFound() { |
|||
setVisibility(ResumeVisibility.APPLIED_COMPANIES); |
|||
StudentProfile profile = profileRepository.findByUserId(studentUserId).orElseThrow(); |
|||
profile.setResumeUrl(null); |
|||
profileRepository.saveAndFlush(profile); |
|||
|
|||
assertThat(perform(companyToken())).as("授权通过但无简历应 404").isEqualTo(404); |
|||
} |
|||
|
|||
// ── 响应脱敏 ───────────────────────────────────────────────
|
|||
|
|||
@Test |
|||
void responseDoesNotLeakPathOrIdentity() throws Exception { |
|||
setVisibility(ResumeVisibility.APPLIED_COMPANIES); |
|||
|
|||
String body = mockMvc.perform(get(url()).header("Authorization", companyToken())) |
|||
.andExpect(status().isOk()) |
|||
.andReturn().getResponse().getContentAsString(); |
|||
|
|||
assertThat(body).as("不得回显存储路径").doesNotContain(storagePath); |
|||
assertThat(body).doesNotContain("resumeUrl"); |
|||
assertThat(body).doesNotContain(String.valueOf(studentUserId)); |
|||
assertThat(body).doesNotContain("studentUserId"); |
|||
} |
|||
|
|||
@Test |
|||
void contentDispositionUsesSanitizedOriginalName() throws Exception { |
|||
setVisibility(ResumeVisibility.APPLIED_COMPANIES); |
|||
|
|||
mockMvc.perform(get(url()).header("Authorization", companyToken())) |
|||
.andExpect(status().isOk()) |
|||
.andExpect(header().string("Content-Disposition", |
|||
org.hamcrest.Matchers.containsString("attachment"))) |
|||
.andExpect(header().string("Content-Disposition", |
|||
org.hamcrest.Matchers.not(org.hamcrest.Matchers.containsString("../")))) |
|||
.andExpect(header().string("Cache-Control", "no-store")); |
|||
} |
|||
|
|||
// ── 企业候选人列表/详情仍不含 resumeUrl ─────────────────────
|
|||
|
|||
@Test |
|||
void companyCandidateEndpointsStillExcludeResumeUrl() throws Exception { |
|||
setVisibility(ResumeVisibility.APPLIED_COMPANIES); |
|||
String token = companyToken(); |
|||
|
|||
String detail = mockMvc.perform(get("/company/candidates/" + applicationId) |
|||
.header("Authorization", token)) |
|||
.andExpect(status().isOk()) |
|||
.andReturn().getResponse().getContentAsString(); |
|||
assertThat(detail).doesNotContain("resumeUrl"); |
|||
|
|||
String list = mockMvc.perform(get("/company/candidates").header("Authorization", token)) |
|||
.andExpect(status().isOk()) |
|||
.andReturn().getResponse().getContentAsString(); |
|||
assertThat(list).doesNotContain("resumeUrl"); |
|||
} |
|||
|
|||
// ── 运行时 OpenAPI 契约(S03 只新增一个企业下载 operation)──
|
|||
|
|||
@Test |
|||
void openApiContractForCompanyResumeDownload() throws Exception { |
|||
String json = mockMvc.perform(get("/v3/api-docs")) |
|||
.andExpect(status().isOk()) |
|||
.andReturn().getResponse().getContentAsString(); |
|||
|
|||
var root = new com.fasterxml.jackson.databind.ObjectMapper().readTree(json); |
|||
var paths = root.path("paths"); |
|||
var schemas = root.path("components").path("schemas"); |
|||
|
|||
long pathCount = paths.size(); |
|||
long opCount = 0; |
|||
var it = paths.fieldNames(); |
|||
while (it.hasNext()) { |
|||
var ops = paths.path(it.next()); |
|||
var f = ops.fieldNames(); |
|||
while (f.hasNext()) { |
|||
if (java.util.Set.of("get", "post", "put", "delete", "patch") |
|||
.contains(f.next())) { |
|||
opCount++; |
|||
} |
|||
} |
|||
} |
|||
assertThat(pathCount).as("S03 基线应为 176 paths").isEqualTo(176); |
|||
assertThat(opCount).as("S03 基线应为 214 operations").isEqualTo(214); |
|||
|
|||
var download = paths.path("/company/candidates/{applicationId}/resume").path("get"); |
|||
assertThat(download.isMissingNode()).as("必须存在企业简历下载 GET").isFalse(); |
|||
assertThat(download.has("requestBody")).as("下载接口不得有 requestBody").isFalse(); |
|||
|
|||
// 不得存在按 studentUserId / candidateKey 下载简历的入口
|
|||
var names = new java.util.ArrayList<String>(); |
|||
paths.fieldNames().forEachRemaining(names::add); |
|||
for (String p : names) { |
|||
String lower = p.toLowerCase(); |
|||
if (lower.contains("resume") || lower.contains("download")) { |
|||
assertThat(lower).as("不得按 studentUserId 下载:" + p).doesNotContain("studentuserid"); |
|||
assertThat(lower).as("不得按 candidateKey 下载:" + p).doesNotContain("candidatekey"); |
|||
} |
|||
} |
|||
|
|||
// 企业候选人 DTO 不得含 resumeUrl / studentUserId / phone / email / storagePath
|
|||
for (String dto : new String[]{"CandidateDetailView", "CandidateItemView"}) { |
|||
var node = schemas.path(dto); |
|||
if (node.isMissingNode()) { |
|||
continue; |
|||
} |
|||
String text = node.toString(); |
|||
for (String forbidden : new String[]{"resumeUrl", "studentUserId", "phone", "email", "storagePath"}) { |
|||
assertThat(text).as(dto + " 不得包含 " + forbidden).doesNotContain(forbidden); |
|||
} |
|||
} |
|||
|
|||
// Actuator 不得进入业务 OpenAPI
|
|||
for (String p : names) { |
|||
assertThat(p).as("Actuator 不得出现在业务契约").doesNotStartWith("/actuator"); |
|||
} |
|||
|
|||
// 开发辅助:-Ds03.openapi.snapshot=<目录> 时导出快照
|
|||
String snapshotDir = System.getProperty("s03.openapi.snapshot"); |
|||
if (snapshotDir != null && !snapshotDir.isBlank()) { |
|||
java.nio.file.Files.write( |
|||
java.nio.file.Paths.get(snapshotDir, "openapi-rc.json"), |
|||
json.getBytes(StandardCharsets.UTF_8)); |
|||
} |
|||
} |
|||
|
|||
// ── 副作用:企业下载只读 ───────────────────────────────────
|
|||
|
|||
/** |
|||
* 表名**固定白名单**(不做任意外部表名拼接)+ 每张表明确选择的稳定业务字段。 |
|||
* 字段名以真实表结构为准。 |
|||
*/ |
|||
private static java.util.Map<String, String> digestColumns() { |
|||
java.util.Map<String, String> m = new java.util.LinkedHashMap<>(); |
|||
m.put("users", "id, phone, email, role, is_active, is_verified, updated_at"); |
|||
m.put("user_roles", "user_id, role"); |
|||
m.put("student_profiles", |
|||
"id, user_id, resume_url, resume_original_name, resume_content_type, resume_size, updated_at"); |
|||
m.put("resume_versions", |
|||
"id, user_id, version_no, status, file_storage_path, file_name, content_type, file_size, updated_at"); |
|||
m.put("applications", "id, job_id, student_user_id, status, updated_at"); |
|||
m.put("jobs", "id, company_id, status, updated_at"); |
|||
m.put("companies", "id, owner_user_id, verified, name, updated_at"); |
|||
m.put("auth_tokens", "id, user_id, session_id, revoked, consumed_at, revoked_at, version, created_at"); |
|||
m.put("audit_logs", "id, user_id, action, entity_type, entity_id, details, created_at"); |
|||
m.put("candidate_dimension_scores", |
|||
"id, student_user_id, dimension, period, score, status, weight, rules_version, calculated_at, version"); |
|||
m.put("notifications", "id, user_id, type, title, is_read, read_at, created_at"); |
|||
m.put("messages", "id, conversation_id, sender_user_id, content, status, recalled_at, version, created_at"); |
|||
m.put("scoring_risk_events", |
|||
"id, rule_config_id, subject_user_id, source_action, severity, occurrence_count, status, detected_at, version"); |
|||
m.put("scoring_risk_actions", "id, risk_event_id, operator_user_id, action, reason_code, created_at"); |
|||
m.put("talent_invitations", |
|||
"id, company_id, job_id, student_user_id, status, expires_at, decided_at, version, created_at"); |
|||
return m; |
|||
} |
|||
|
|||
/** |
|||
* 行级快照:按选定字段排序后取全部行,做深比较。 |
|||
* |
|||
* <p>不使用 COUNT / SUM(id) / CRC32(id) 这类弱摘要;查询异常直接抛出使测试失败, |
|||
* 不允许被吞掉后写入占位值。</p> |
|||
*/ |
|||
private java.util.Map<String, java.util.List<java.util.Map<String, Object>>> snapshot() { |
|||
java.util.Map<String, java.util.List<java.util.Map<String, Object>>> out = new java.util.LinkedHashMap<>(); |
|||
for (java.util.Map.Entry<String, String> e : digestColumns().entrySet()) { |
|||
String columns = e.getValue(); |
|||
String sql = "SELECT " + columns + " FROM " + e.getKey() + " ORDER BY " + columns; |
|||
out.put(e.getKey(), jdbcTemplate.queryForList(sql)); |
|||
} |
|||
return out; |
|||
} |
|||
|
|||
@Test |
|||
void companyDownloadHasNoSideEffectsOnBusinessTables() { |
|||
setVisibility(ResumeVisibility.APPLIED_COMPANIES); |
|||
|
|||
// 先取 token:测试夹具在签发时会写入 auth_tokens/users,必须发生在基线快照之前,
|
|||
// 否则夹具自身的记录会被误判为「下载接口的副作用」。
|
|||
String token = companyToken(); |
|||
|
|||
var before = snapshot(); |
|||
|
|||
assertThat(perform(token)).as("授权通过应 200").isEqualTo(200); |
|||
|
|||
var after = snapshot(); |
|||
for (String table : before.keySet()) { |
|||
assertThat(after.get(table)) |
|||
.as(table + " 不得因企业下载而变化(逐行内容比较)") |
|||
.isEqualTo(before.get(table)); |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 反证:摘要必须能发现单行变化,否则前面的「零变化」断言没有说服力。 |
|||
*/ |
|||
@Test |
|||
void snapshotDetectsSingleRowChange() { |
|||
var before = snapshot(); |
|||
|
|||
Application app = applicationRepository.findById(applicationId).orElseThrow(); |
|||
String original = app.getStatus(); |
|||
app.setStatus(original.equals("INTERVIEW") ? "HIRED" : "INTERVIEW"); |
|||
applicationRepository.saveAndFlush(app); |
|||
|
|||
try { |
|||
var after = snapshot(); |
|||
assertThat(after.get("applications")) |
|||
.as("单行 status 变化必须被摘要发现").isNotEqualTo(before.get("applications")); |
|||
} finally { |
|||
Application restore = applicationRepository.findById(applicationId).orElseThrow(); |
|||
restore.setStatus(original); |
|||
applicationRepository.saveAndFlush(restore); |
|||
} |
|||
} |
|||
|
|||
@Test |
|||
void snapshotCoversEveryRequiredTableAndColumn() { |
|||
var snap = snapshot(); |
|||
for (String table : new String[]{ |
|||
"users", "user_roles", "student_profiles", "resume_versions", "applications", |
|||
"jobs", "companies", "auth_tokens", "audit_logs", "candidate_dimension_scores", |
|||
"notifications", "messages", "scoring_risk_events", "scoring_risk_actions", |
|||
"talent_invitations"}) { |
|||
assertThat(snap).as("必须覆盖 " + table).containsKey(table); |
|||
} |
|||
// 抽查关键字段确实被取到(而非空 Map)
|
|||
assertThat(snap.get("applications").get(0)).containsKeys("status", "updated_at"); |
|||
assertThat(snap.get("auth_tokens").get(0)).containsKeys("revoked", "version"); |
|||
} |
|||
|
|||
// ── 工具 ───────────────────────────────────────────────────
|
|||
|
|||
private int perform(String token) { |
|||
try { |
|||
return mockMvc.perform(get(url()).header("Authorization", token)) |
|||
.andReturn().getResponse().getStatus(); |
|||
} catch (Exception e) { |
|||
return -1; |
|||
} |
|||
} |
|||
|
|||
private int performNoAuth() { |
|||
try { |
|||
return mockMvc.perform(get(url())).andReturn().getResponse().getStatus(); |
|||
} catch (Exception e) { |
|||
return -1; |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,299 @@ |
|||
package com.mydreamjob.auth; |
|||
|
|||
import ch.qos.logback.classic.Level; |
|||
import ch.qos.logback.classic.Logger; |
|||
import ch.qos.logback.classic.spi.ILoggingEvent; |
|||
import ch.qos.logback.classic.spi.IThrowableProxy; |
|||
import ch.qos.logback.classic.spi.StackTraceElementProxy; |
|||
import ch.qos.logback.core.read.ListAppender; |
|||
import org.junit.jupiter.api.AfterEach; |
|||
import org.junit.jupiter.api.BeforeEach; |
|||
import org.junit.jupiter.api.Test; |
|||
import org.slf4j.LoggerFactory; |
|||
import org.springframework.beans.factory.annotation.Autowired; |
|||
import org.springframework.boot.test.context.SpringBootTest; |
|||
import org.springframework.boot.test.mock.mockito.MockBean; |
|||
import org.springframework.data.redis.core.RedisTemplate; |
|||
import org.springframework.jdbc.core.JdbcTemplate; |
|||
import org.springframework.test.context.ActiveProfiles; |
|||
|
|||
import java.util.ArrayList; |
|||
import java.util.List; |
|||
import java.util.concurrent.*; |
|||
|
|||
import static org.assertj.core.api.Assertions.assertThat; |
|||
|
|||
/** |
|||
* S03 定向整改:管理员初始化并发冲突**不得**把完整手机号写进任何日志。 |
|||
* |
|||
* <p>并发竞争由数据库唯一约束兜底时,Hibernate 的 {@code SqlExceptionHelper} 会在业务代码 |
|||
* 捕获异常**之前**记录整条 SQL 与绑定参数 —— 手机号因此进入日志。本类捕获 |
|||
* root / {@code com.mydreamjob} / {@code org.hibernate} / {@code org.springframework.jdbc} |
|||
* 四类 logger,并把 {@code throwableProxy} 的完整文本(含 cause 链与堆栈)纳入扫描。</p> |
|||
*/ |
|||
@SpringBootTest(properties = { |
|||
"app.bootstrap.admin.enabled=true", |
|||
"app.bootstrap.admin.phone=13800000666", |
|||
"app.bootstrap.admin.nickname=并发引导管理员", |
|||
}) |
|||
@ActiveProfiles("integration-test") |
|||
class AdminBootstrapConcurrencyLoggingTest { |
|||
|
|||
private static final String BOOTSTRAP_PHONE = "13800000666"; |
|||
|
|||
@MockBean |
|||
private RedisTemplate<String, Object> redisTemplate; |
|||
|
|||
@Autowired |
|||
private AdminBootstrapRunner runner; |
|||
|
|||
@Autowired |
|||
private JdbcTemplate jdbcTemplate; |
|||
|
|||
private final List<ListAppender<ILoggingEvent>> appenders = new ArrayList<>(); |
|||
private final List<Logger> attachedLoggers = new ArrayList<>(); |
|||
private final List<Long> deactivatedAdmins = new ArrayList<>(); |
|||
|
|||
private static final String[] CAPTURED_LOGGERS = { |
|||
org.slf4j.Logger.ROOT_LOGGER_NAME, |
|||
"com.mydreamjob", |
|||
"org.hibernate", |
|||
"org.springframework.jdbc", |
|||
}; |
|||
|
|||
@BeforeEach |
|||
void setUp() { |
|||
jdbcTemplate.update("DELETE FROM user_roles WHERE user_id IN (SELECT id FROM users WHERE phone = ?)", |
|||
BOOTSTRAP_PHONE); |
|||
jdbcTemplate.update("DELETE FROM users WHERE phone = ?", BOOTSTRAP_PHONE); |
|||
|
|||
// 保证「无活动 ADMIN」前置条件与执行顺序无关
|
|||
deactivatedAdmins.clear(); |
|||
for (Long id : jdbcTemplate.queryForList( |
|||
"SELECT id FROM users WHERE role = 'ADMIN' AND is_active = 1", Long.class)) { |
|||
jdbcTemplate.update("UPDATE users SET is_active = 0 WHERE id = ?", id); |
|||
deactivatedAdmins.add(id); |
|||
} |
|||
|
|||
for (String name : CAPTURED_LOGGERS) { |
|||
Logger logger = (Logger) LoggerFactory.getLogger(name); |
|||
ListAppender<ILoggingEvent> appender = new ListAppender<>(); |
|||
appender.start(); |
|||
logger.addAppender(appender); |
|||
// 刻意**不**下调级别:按应用自身配置(root=WARN、com.mydreamjob=INFO)捕获,
|
|||
// 扫描结果才等于「生产到底写出了什么」。
|
|||
// 框架在 TRACE 下的绑定参数日志不属于生产可见输出,不应计入本断言。
|
|||
appenders.add(appender); |
|||
attachedLoggers.add(logger); |
|||
} |
|||
} |
|||
|
|||
@AfterEach |
|||
void tearDown() { |
|||
for (int i = 0; i < attachedLoggers.size(); i++) { |
|||
attachedLoggers.get(i).detachAppender(appenders.get(i)); |
|||
appenders.get(i).stop(); |
|||
} |
|||
attachedLoggers.clear(); |
|||
appenders.clear(); |
|||
|
|||
jdbcTemplate.update("DELETE FROM user_roles WHERE user_id IN (SELECT id FROM users WHERE phone = ?)", |
|||
BOOTSTRAP_PHONE); |
|||
jdbcTemplate.update("DELETE FROM users WHERE phone = ?", BOOTSTRAP_PHONE); |
|||
for (Long id : deactivatedAdmins) { |
|||
jdbcTemplate.update("UPDATE users SET is_active = 1 WHERE id = ?", id); |
|||
} |
|||
deactivatedAdmins.clear(); |
|||
} |
|||
|
|||
// ── 日志采集(含 throwableProxy 全文)────────────────────────
|
|||
|
|||
/** 把 throwableProxy 的类名、message、堆栈与 cause 链完整渲染为文本。 */ |
|||
private static String renderThrowable(IThrowableProxy proxy) { |
|||
if (proxy == null) { |
|||
return ""; |
|||
} |
|||
StringBuilder sb = new StringBuilder(); |
|||
sb.append(proxy.getClassName()).append(": ").append(proxy.getMessage()).append('\n'); |
|||
StackTraceElementProxy[] stack = proxy.getStackTraceElementProxyArray(); |
|||
if (stack != null) { |
|||
for (StackTraceElementProxy s : stack) { |
|||
sb.append(" at ").append(s.getStackTraceElement()).append('\n'); |
|||
} |
|||
} |
|||
if (proxy.getCause() != null) { |
|||
sb.append("Caused by: ").append(renderThrowable(proxy.getCause())); |
|||
} |
|||
return sb.toString(); |
|||
} |
|||
|
|||
/** |
|||
* 所有被捕获 logger 的完整文本:formattedMessage + 参数 + throwable 全文。 |
|||
* |
|||
* <p>排除 {@code org.springframework.test.*}:测试框架自身会在 DEBUG 记录 |
|||
* 上下文缓存键(其中包含本次 {@code @SpringBootTest} 的属性列表,因而含手机号)。 |
|||
* 那是测试基础设施的输出,不是产品日志 —— 生产运行不存在该 logger。</p> |
|||
*/ |
|||
private String capturedText() { |
|||
StringBuilder sb = new StringBuilder(); |
|||
for (ListAppender<ILoggingEvent> appender : appenders) { |
|||
for (ILoggingEvent e : appender.list) { |
|||
if (e.getLoggerName() != null && e.getLoggerName().startsWith("org.springframework.test")) { |
|||
continue; |
|||
} |
|||
sb.append(e.getFormattedMessage()).append('\n'); |
|||
Object[] args = e.getArgumentArray(); |
|||
if (args != null) { |
|||
for (Object a : args) { |
|||
sb.append(String.valueOf(a)).append('\n'); |
|||
} |
|||
} |
|||
sb.append(renderThrowable(e.getThrowableProxy())); |
|||
} |
|||
} |
|||
return sb.toString(); |
|||
} |
|||
|
|||
/** 扫描器有效性探针:注入一个标记,必须能被同一个扫描函数命中。 */ |
|||
@Test |
|||
void scannerDetectsInjectedProbe() { |
|||
Logger logger = (Logger) LoggerFactory.getLogger("com.mydreamjob"); |
|||
String probe = "S03_SCANNER_PROBE_" + BOOTSTRAP_PHONE; |
|||
logger.error("probe {}", probe); |
|||
assertThat(capturedText()).as("扫描器必须能命中注入的探针,证明不是空转") |
|||
.contains(probe); |
|||
} |
|||
|
|||
// ── 并发冲突不得泄漏手机号 ──────────────────────────────────
|
|||
|
|||
private List<String> runConcurrently() throws Exception { |
|||
ExecutorService pool = Executors.newFixedThreadPool(2); |
|||
CountDownLatch start = new CountDownLatch(1); |
|||
CountDownLatch ready = new CountDownLatch(2); |
|||
List<Future<String>> futures = new ArrayList<>(); |
|||
for (int i = 0; i < 2; i++) { |
|||
futures.add(pool.submit(() -> { |
|||
ready.countDown(); |
|||
start.await(20, TimeUnit.SECONDS); |
|||
try { |
|||
runner.run(null); |
|||
return "SUCCESS"; |
|||
} catch (Throwable t) { |
|||
return t.getClass().getName(); |
|||
} |
|||
})); |
|||
} |
|||
assertThat(ready.await(20, TimeUnit.SECONDS)).isTrue(); |
|||
start.countDown(); |
|||
List<String> results = new ArrayList<>(); |
|||
for (Future<String> f : futures) { |
|||
results.add(f.get(60, TimeUnit.SECONDS)); |
|||
} |
|||
pool.shutdownNow(); |
|||
return results; |
|||
} |
|||
|
|||
@Test |
|||
void adminBootstrapConcurrentConflictNeverLogsConfiguredPhone() throws Exception { |
|||
runConcurrently(); |
|||
|
|||
String log = capturedText(); |
|||
assertThat(log).as("完整管理员手机号不得出现在任何 logger(含 throwableProxy)") |
|||
.doesNotContain(BOOTSTRAP_PHONE); |
|||
assertThat(log).as("不得出现 SQL 绑定参数回显").doesNotContain("binding parameter"); |
|||
assertThat(log).as("不得出现 User.toString()").doesNotContain("User(id="); |
|||
assertThat(log).as("不得出现 password/token/secret 值") |
|||
.doesNotContainIgnoringCase("password=") |
|||
.doesNotContainIgnoringCase("secret="); |
|||
} |
|||
|
|||
@Test |
|||
void concurrentBootstrapNeverLogsPhoneAcrossAllLoggers() throws Exception { |
|||
List<String> results = runConcurrently(); |
|||
|
|||
String log = capturedText(); |
|||
assertThat(log).doesNotContain(BOOTSTRAP_PHONE); |
|||
|
|||
// 冲突方不得把原始 Hibernate/JDBC 异常带出来
|
|||
assertThat(results).as("并发结果不得包含原始约束异常类型") |
|||
.noneMatch(r -> r.contains("DataIntegrityViolationException") |
|||
|| r.contains("ConstraintViolationException") |
|||
|| r.contains("SQLException")); |
|||
} |
|||
|
|||
@Test |
|||
void concurrentBootstrapProducesOneValidAdmin() throws Exception { |
|||
runConcurrently(); |
|||
|
|||
assertThat(countByPhone()).as("并发后同手机号用户恰好一行").isEqualTo(1); |
|||
assertThat(jdbcTemplate.queryForObject( |
|||
"SELECT COUNT(*) FROM user_roles ur JOIN users u ON u.id = ur.user_id " |
|||
+ "WHERE u.phone = ? AND ur.role = 'ADMIN'", Integer.class, BOOTSTRAP_PHONE)) |
|||
.as("user_roles 中 ADMIN 恰好一行").isEqualTo(1); |
|||
assertThat(jdbcTemplate.queryForObject( |
|||
"SELECT COUNT(*) FROM user_roles ur JOIN users u ON u.id = ur.user_id " |
|||
+ "WHERE u.phone = ? AND ur.role IN ('STUDENT','COMPANY')", Integer.class, BOOTSTRAP_PHONE)) |
|||
.as("不得出现 STUDENT/COMPANY 附加角色").isZero(); |
|||
|
|||
List<java.util.Map<String, Object>> row = jdbcTemplate.queryForList( |
|||
"SELECT is_active, is_verified, password_hash FROM users WHERE phone = ?", BOOTSTRAP_PHONE); |
|||
assertThat(row).hasSize(1); |
|||
assertThat(((Number) row.get(0).get("is_active")).intValue()).isEqualTo(1); |
|||
assertThat(((Number) row.get(0).get("is_verified")).intValue()).isEqualTo(1); |
|||
assertThat(row.get(0).get("password_hash")).as("不得设置默认密码").isNull(); |
|||
} |
|||
|
|||
@Test |
|||
void repeatedBootstrapRemainsIdempotent() { |
|||
runner.run(null); |
|||
runner.run(null); |
|||
runner.run(null); |
|||
|
|||
assertThat(countByPhone()).isEqualTo(1); |
|||
assertThat(jdbcTemplate.queryForObject( |
|||
"SELECT COUNT(*) FROM users WHERE role='ADMIN' AND is_active=1", Integer.class)) |
|||
.isEqualTo(1); |
|||
} |
|||
|
|||
@Test |
|||
void existingStudentWithSamePhoneStillFailsWithoutPrivilegeEscalation() { |
|||
jdbcTemplate.update( |
|||
"INSERT INTO users (phone, nickname, password_hash, role, is_active, is_verified) " |
|||
+ "VALUES (?, '普通学生', NULL, 'STUDENT', 1, 1)", BOOTSTRAP_PHONE); |
|||
|
|||
try { |
|||
assertThat(org.junit.jupiter.api.Assertions.assertThrows(RuntimeException.class, |
|||
() -> runner.run(null)).getMessage()) |
|||
.as("冲突文案必须固定且不含手机号") |
|||
.doesNotContain(BOOTSTRAP_PHONE); |
|||
} finally { |
|||
jdbcTemplate.update("DELETE FROM users WHERE phone = ?", BOOTSTRAP_PHONE); |
|||
} |
|||
|
|||
assertThat(capturedText()).as("冲突路径也不得把手机号写进日志") |
|||
.doesNotContain(BOOTSTRAP_PHONE); |
|||
} |
|||
|
|||
@Test |
|||
void conflictExceptionHasNoSensitiveCause() { |
|||
jdbcTemplate.update( |
|||
"INSERT INTO users (phone, nickname, password_hash, role, is_active, is_verified) " |
|||
+ "VALUES (?, '占位用户', NULL, 'STUDENT', 1, 1)", BOOTSTRAP_PHONE); |
|||
|
|||
try { |
|||
RuntimeException ex = org.junit.jupiter.api.Assertions.assertThrows(RuntimeException.class, |
|||
() -> runner.run(null)); |
|||
assertThat(ex.getMessage()).doesNotContain(BOOTSTRAP_PHONE); |
|||
assertThat(ex.getCause()).as("不得携带原始数据库异常作为 cause").isNull(); |
|||
assertThat(ex.getClass().getName()).doesNotContain("DataIntegrityViolation"); |
|||
} finally { |
|||
jdbcTemplate.update("DELETE FROM users WHERE phone = ?", BOOTSTRAP_PHONE); |
|||
} |
|||
} |
|||
|
|||
private int countByPhone() { |
|||
Integer n = jdbcTemplate.queryForObject( |
|||
"SELECT COUNT(*) FROM users WHERE phone = ?", Integer.class, BOOTSTRAP_PHONE); |
|||
return n == null ? 0 : n; |
|||
} |
|||
} |
|||
@ -0,0 +1,41 @@ |
|||
package com.mydreamjob.auth; |
|||
|
|||
import com.mydreamjob.config.AdminBootstrapProperties; |
|||
import org.junit.jupiter.api.Test; |
|||
import org.springframework.beans.factory.annotation.Autowired; |
|||
import org.springframework.boot.test.context.SpringBootTest; |
|||
import org.springframework.boot.test.mock.mockito.MockBean; |
|||
import org.springframework.context.ApplicationContext; |
|||
import org.springframework.data.redis.core.RedisTemplate; |
|||
import org.springframework.test.context.ActiveProfiles; |
|||
|
|||
import static org.assertj.core.api.Assertions.assertThat; |
|||
|
|||
/** |
|||
* S03-A:不显式开启时,初始化器**不得**被装配,普通启动不会隐式创建管理员。 |
|||
*/ |
|||
@SpringBootTest |
|||
@ActiveProfiles("integration-test") |
|||
class AdminBootstrapDisabledByDefaultTest { |
|||
|
|||
@MockBean |
|||
private RedisTemplate<String, Object> redisTemplate; |
|||
|
|||
@Autowired |
|||
private ApplicationContext context; |
|||
|
|||
@Autowired |
|||
private AdminBootstrapProperties properties; |
|||
|
|||
@Test |
|||
void runnerIsNotAssembledByDefault() { |
|||
assertThat(context.getBeanNamesForType(AdminBootstrapRunner.class)) |
|||
.as("默认(开关未开启)不得装配管理员初始化器").isEmpty(); |
|||
} |
|||
|
|||
@Test |
|||
void defaultPropertiesAreDisabledAndHaveNoPhone() { |
|||
assertThat(properties.getEnabled()).isEqualTo("false"); |
|||
assertThat(properties.getPhone()).isEmpty(); |
|||
} |
|||
} |
|||
@ -0,0 +1,235 @@ |
|||
package com.mydreamjob.auth; |
|||
|
|||
import com.mydreamjob.config.AdminBootstrapProperties; |
|||
import org.junit.jupiter.api.AfterEach; |
|||
import org.junit.jupiter.api.BeforeEach; |
|||
import org.junit.jupiter.api.Test; |
|||
import org.springframework.beans.factory.annotation.Autowired; |
|||
import org.springframework.boot.test.context.SpringBootTest; |
|||
import org.springframework.boot.test.mock.mockito.MockBean; |
|||
import org.springframework.context.ApplicationContext; |
|||
import org.springframework.data.redis.core.RedisTemplate; |
|||
import org.springframework.jdbc.core.JdbcTemplate; |
|||
import org.springframework.test.context.ActiveProfiles; |
|||
|
|||
import java.util.List; |
|||
import java.util.concurrent.*; |
|||
|
|||
import static org.assertj.core.api.Assertions.assertThat; |
|||
|
|||
/** |
|||
* S03-A 管理员初始化的真实上下文验证:条件装配、真实 H2 建号、 |
|||
* {@code users.role} 与 {@code user_roles} 一致、幂等、并发冲突。 |
|||
*/ |
|||
@SpringBootTest(properties = { |
|||
"app.bootstrap.admin.enabled=true", |
|||
"app.bootstrap.admin.phone=13800000777", |
|||
"app.bootstrap.admin.nickname=引导管理员", |
|||
}) |
|||
@ActiveProfiles("integration-test") |
|||
class AdminBootstrapIntegrationTest { |
|||
|
|||
private static final String BOOTSTRAP_PHONE = "13800000777"; |
|||
|
|||
@MockBean |
|||
private RedisTemplate<String, Object> redisTemplate; |
|||
|
|||
@Autowired |
|||
private ApplicationContext context; |
|||
|
|||
@Autowired |
|||
private AdminBootstrapRunner runner; |
|||
|
|||
@Autowired |
|||
private AdminBootstrapProperties properties; |
|||
|
|||
@Autowired |
|||
private UserRepository userRepository; |
|||
|
|||
@Autowired |
|||
private JdbcTemplate jdbcTemplate; |
|||
|
|||
private ch.qos.logback.core.read.ListAppender<ch.qos.logback.classic.spi.ILoggingEvent> appender; |
|||
private ch.qos.logback.classic.Logger appLogger; |
|||
/** 用例开始前被临时停用、结束后需恢复的其他 ADMIN id。 */ |
|||
private final java.util.List<Long> deactivatedAdmins = new java.util.ArrayList<>(); |
|||
|
|||
@BeforeEach |
|||
void clearBootstrapArtifacts() { |
|||
// 清掉本测试可能留下的引导账号,使「无活动 ADMIN」前置条件成立
|
|||
jdbcTemplate.update("DELETE FROM user_roles WHERE user_id IN (SELECT id FROM users WHERE phone = ?)", |
|||
BOOTSTRAP_PHONE); |
|||
jdbcTemplate.update("DELETE FROM users WHERE phone = ?", BOOTSTRAP_PHONE); |
|||
|
|||
// 其他测试类可能留下活动 ADMIN,破坏「无活动 ADMIN」前置条件。这里临时停用它们,
|
|||
// 用例结束恢复,使本类的断言不依赖测试执行顺序。
|
|||
deactivatedAdmins.clear(); |
|||
for (Long id : jdbcTemplate.queryForList( |
|||
"SELECT id FROM users WHERE role = 'ADMIN' AND is_active = 1", Long.class)) { |
|||
jdbcTemplate.update("UPDATE users SET is_active = 0 WHERE id = ?", id); |
|||
deactivatedAdmins.add(id); |
|||
} |
|||
|
|||
appLogger = (ch.qos.logback.classic.Logger) |
|||
org.slf4j.LoggerFactory.getLogger("com.mydreamjob"); |
|||
appender = new ch.qos.logback.core.read.ListAppender<>(); |
|||
appender.start(); |
|||
appLogger.addAppender(appender); |
|||
appLogger.setLevel(ch.qos.logback.classic.Level.TRACE); |
|||
} |
|||
|
|||
@AfterEach |
|||
void tearDown() { |
|||
if (appLogger != null && appender != null) { |
|||
appLogger.detachAppender(appender); |
|||
appender.stop(); |
|||
} |
|||
jdbcTemplate.update("DELETE FROM user_roles WHERE user_id IN (SELECT id FROM users WHERE phone = ?)", |
|||
BOOTSTRAP_PHONE); |
|||
jdbcTemplate.update("DELETE FROM users WHERE phone = ?", BOOTSTRAP_PHONE); |
|||
|
|||
// 恢复本用例开始前临时停用的其他 ADMIN
|
|||
for (Long id : deactivatedAdmins) { |
|||
jdbcTemplate.update("UPDATE users SET is_active = 1 WHERE id = ?", id); |
|||
} |
|||
deactivatedAdmins.clear(); |
|||
} |
|||
|
|||
private long activeAdmins() { |
|||
return userRepository.countByRoleAndActiveTrue(User.Role.ADMIN); |
|||
} |
|||
|
|||
@Test |
|||
void runnerIsAssembledWhenPropertyIsTrue() { |
|||
assertThat(runner).as("开关为 true 时应装配初始化器").isNotNull(); |
|||
assertThat(properties.getNickname()).isEqualTo("引导管理员"); |
|||
} |
|||
|
|||
@Test |
|||
void createsAdminConsistentAcrossUsersAndUserRoles() { |
|||
runner.run(null); |
|||
|
|||
List<java.util.Map<String, Object>> rows = jdbcTemplate.queryForList( |
|||
"SELECT id, role, is_active, is_verified FROM users WHERE phone = ?", BOOTSTRAP_PHONE); |
|||
assertThat(rows).hasSize(1); |
|||
assertThat(rows.get(0).get("role")).isEqualTo("ADMIN"); |
|||
assertThat(((Number) rows.get(0).get("is_active")).intValue()).isEqualTo(1); |
|||
assertThat(((Number) rows.get(0).get("is_verified")).intValue()).isEqualTo(1); |
|||
|
|||
Long id = ((Number) rows.get(0).get("id")).longValue(); |
|||
List<String> roles = jdbcTemplate.queryForList( |
|||
"SELECT role FROM user_roles WHERE user_id = ?", String.class, id); |
|||
assertThat(roles).as("user_roles 必须与 users.role 一致").containsExactly("ADMIN"); |
|||
|
|||
assertThat(jdbcTemplate.queryForObject( |
|||
"SELECT COUNT(*) FROM users WHERE phone = ?", Integer.class, BOOTSTRAP_PHONE)).isEqualTo(1); |
|||
} |
|||
|
|||
@Test |
|||
void repeatedRunIsIdempotent() { |
|||
runner.run(null); |
|||
long afterFirst = activeAdmins(); |
|||
|
|||
runner.run(null); |
|||
runner.run(null); |
|||
|
|||
assertThat(activeAdmins()).as("重复启动不得创建第二个管理员").isEqualTo(afterFirst); |
|||
assertThat(countByPhone()).isEqualTo(1); |
|||
} |
|||
|
|||
/** |
|||
* 并发初始化的部署契约(**方案 A**):一个线程创建成功,另一个重新查询后判定现存账号 |
|||
* 正是本次配置要求的合法 ADMIN,返回**幂等成功** —— 两个调用均不泄漏异常,且最终只有 |
|||
* 一条 users 与一条 ADMIN user_roles。不使用任何 JVM 锁。 |
|||
*/ |
|||
@Test |
|||
void concurrentBootstrapCannotCreateTwoAdmins() throws Exception { |
|||
// 前置条件:不得已有活动 ADMIN,否则初始化器会按设计跳过,本用例失去意义。
|
|||
assertThat(activeAdmins()).as("并发用例前置条件:不得已有活动 ADMIN").isZero(); |
|||
|
|||
int threads = 2; |
|||
ExecutorService pool = Executors.newFixedThreadPool(threads); |
|||
CountDownLatch start = new CountDownLatch(1); |
|||
CountDownLatch ready = new CountDownLatch(threads); |
|||
CountDownLatch finished = new CountDownLatch(threads); |
|||
|
|||
List<Future<String>> outcomes = new java.util.ArrayList<>(); |
|||
for (int i = 0; i < threads; i++) { |
|||
outcomes.add(pool.submit(() -> { |
|||
ready.countDown(); |
|||
start.await(20, TimeUnit.SECONDS); // 屏障:两线程同时开始
|
|||
try { |
|||
runner.run(null); |
|||
return "SUCCESS"; // 方案 A:创建成功或幂等成功
|
|||
} catch (Throwable t) { |
|||
return "UNKNOWN:" + t.getClass().getName(); |
|||
} finally { |
|||
finished.countDown(); |
|||
} |
|||
})); |
|||
} |
|||
|
|||
assertThat(ready.await(20, TimeUnit.SECONDS)).as("两线程都已到达屏障").isTrue(); |
|||
start.countDown(); |
|||
|
|||
List<String> results = new java.util.ArrayList<>(); |
|||
for (Future<String> f : outcomes) { |
|||
results.add(f.get(60, TimeUnit.SECONDS)); |
|||
} |
|||
pool.shutdownNow(); |
|||
assertThat(finished.await(5, TimeUnit.SECONDS)).as("两线程都已结束").isTrue(); |
|||
|
|||
long success = results.stream().filter("SUCCESS"::equals).count(); |
|||
long unknown = results.stream().filter(r -> r.startsWith("UNKNOWN")).count(); |
|||
|
|||
// 方案 A:一个创建、一个幂等成功 —— 两个调用都必须成功,且都不得抛出异常。
|
|||
assertThat(success).as("两个调用都应成功(实测 " + results + ")").isEqualTo(2); |
|||
assertThat(unknown).as("未知异常必须为 0(实测 " + results + ")").isZero(); |
|||
|
|||
// 最终数据状态
|
|||
assertThat(countByPhone()).as("同手机号用户恰好 1 个").isEqualTo(1); |
|||
assertThat(activeAdmins()).as("活动 ADMIN 恰好 1 个").isEqualTo(1); |
|||
assertThat(adminRoleRows()).as("user_roles 中 ADMIN 恰好 1 行").isEqualTo(1); |
|||
assertThat(templateHash()).as("不得设置默认密码").isNull(); |
|||
assertThat(otherRoleRows()).as("不得出现 STUDENT/COMPANY 角色").isZero(); |
|||
|
|||
// 异常与日志不得泄漏手机号、SQL 参数或用户对象
|
|||
String log = logAppenderText(); |
|||
assertThat(log).as("日志不得包含手机号").doesNotContain(BOOTSTRAP_PHONE); |
|||
assertThat(log).as("日志不得包含 SQL 参数占位").doesNotContain("bind"); |
|||
assertThat(log).as("日志不得包含用户对象").doesNotContain("User(id="); |
|||
} |
|||
|
|||
private int adminRoleRows() { |
|||
Integer n = jdbcTemplate.queryForObject( |
|||
"SELECT COUNT(*) FROM user_roles ur JOIN users u ON u.id = ur.user_id " |
|||
+ "WHERE u.phone = ? AND ur.role = 'ADMIN'", Integer.class, BOOTSTRAP_PHONE); |
|||
return n == null ? 0 : n; |
|||
} |
|||
|
|||
private int otherRoleRows() { |
|||
Integer n = jdbcTemplate.queryForObject( |
|||
"SELECT COUNT(*) FROM user_roles ur JOIN users u ON u.id = ur.user_id " |
|||
+ "WHERE u.phone = ? AND ur.role IN ('STUDENT','COMPANY')", |
|||
Integer.class, BOOTSTRAP_PHONE); |
|||
return n == null ? 0 : n; |
|||
} |
|||
|
|||
private String templateHash() { |
|||
return jdbcTemplate.queryForObject( |
|||
"SELECT password_hash FROM users WHERE phone = ?", String.class, BOOTSTRAP_PHONE); |
|||
} |
|||
|
|||
private String logAppenderText() { |
|||
return appender == null ? "" |
|||
: appender.list.stream() |
|||
.map(ch.qos.logback.classic.spi.ILoggingEvent::getFormattedMessage) |
|||
.collect(java.util.stream.Collectors.joining("\n")); |
|||
} |
|||
|
|||
private int countByPhone() { |
|||
Integer n = jdbcTemplate.queryForObject( |
|||
"SELECT COUNT(*) FROM users WHERE phone = ?", Integer.class, BOOTSTRAP_PHONE); |
|||
return n == null ? 0 : n; |
|||
} |
|||
} |
|||
@ -0,0 +1,237 @@ |
|||
package com.mydreamjob.auth; |
|||
|
|||
import ch.qos.logback.classic.Level; |
|||
import ch.qos.logback.classic.Logger; |
|||
import ch.qos.logback.classic.spi.ILoggingEvent; |
|||
import ch.qos.logback.core.read.ListAppender; |
|||
import com.mydreamjob.config.AdminBootstrapProperties; |
|||
import org.junit.jupiter.api.AfterEach; |
|||
import org.junit.jupiter.api.BeforeEach; |
|||
import org.junit.jupiter.api.Test; |
|||
import org.junit.jupiter.api.extension.ExtendWith; |
|||
import org.mockito.Mock; |
|||
import org.mockito.junit.jupiter.MockitoExtension; |
|||
import org.slf4j.LoggerFactory; |
|||
|
|||
import java.util.List; |
|||
import java.util.Optional; |
|||
import java.util.stream.Collectors; |
|||
|
|||
import static org.junit.jupiter.api.Assertions.*; |
|||
import static org.mockito.ArgumentMatchers.any; |
|||
import static org.mockito.Mockito.*; |
|||
|
|||
/** |
|||
* S03-A 管理员初始化的规则分支(设计 §3.2)。 |
|||
* |
|||
* <p>真实 {@link AdminBootstrapRunner} + Mock 仓储,逐条覆盖任务书 §四 要求的场景。 |
|||
* 日志脱敏用真实 Logback 捕获验证。</p> |
|||
*/ |
|||
@ExtendWith(MockitoExtension.class) |
|||
class AdminBootstrapRunnerTest { |
|||
|
|||
private static final String PHONE = "13800000009"; |
|||
private static final String OTHER_PHONE = "13800000008"; |
|||
|
|||
@Mock private UserRepository userRepository; |
|||
/** S03 整改后创建走独立事务组件(JdbcTemplate),单测以 mock 替代。 */ |
|||
@Mock private AdminBootstrapCreator creator; |
|||
|
|||
private AdminBootstrapProperties properties; |
|||
private AdminBootstrapRunner runner; |
|||
|
|||
private ListAppender<ILoggingEvent> appender; |
|||
private Logger appLogger; |
|||
|
|||
@BeforeEach |
|||
void setUp() { |
|||
properties = new AdminBootstrapProperties(); |
|||
runner = new AdminBootstrapRunner(properties, userRepository, creator); |
|||
|
|||
appLogger = (Logger) LoggerFactory.getLogger("com.mydreamjob"); |
|||
appender = new ListAppender<>(); |
|||
appender.start(); |
|||
appLogger.addAppender(appender); |
|||
appLogger.setLevel(Level.TRACE); |
|||
} |
|||
|
|||
@AfterEach |
|||
void tearDown() { |
|||
if (appLogger != null && appender != null) { |
|||
appLogger.detachAppender(appender); |
|||
appender.stop(); |
|||
} |
|||
} |
|||
|
|||
private String capturedLog() { |
|||
return appender.list.stream().map(ILoggingEvent::getFormattedMessage).collect(Collectors.joining("\n")); |
|||
} |
|||
|
|||
private void enableWith(String phone) { |
|||
properties.setEnabled("true"); |
|||
properties.setPhone(phone); |
|||
} |
|||
|
|||
// ── 关闭 / 未启用 ──────────────────────────────────────────
|
|||
|
|||
@Test |
|||
void disabledDoesNothing() { |
|||
properties.setEnabled("false"); |
|||
properties.setPhone(PHONE); |
|||
|
|||
runner.run(null); |
|||
|
|||
verifyNoInteractions(userRepository); |
|||
verifyNoInteractions(creator); |
|||
} |
|||
|
|||
@Test |
|||
void nonLiteralTrueValueDoesNotEnable() { |
|||
// 只有字面量 "true" 才算启用;大小写变体一律不生效。
|
|||
for (String value : new String[]{"TRUE", "True", "yes", "1", "on", " true "}) { |
|||
properties.setEnabled(value); |
|||
properties.setPhone(PHONE); |
|||
runner.run(null); |
|||
} |
|||
verifyNoInteractions(userRepository); |
|||
verifyNoInteractions(creator); |
|||
} |
|||
|
|||
// ── 手机号校验 ─────────────────────────────────────────────
|
|||
|
|||
@Test |
|||
void missingPhoneFailsAtStartup() { |
|||
properties.setEnabled("true"); |
|||
properties.setPhone(""); |
|||
|
|||
IllegalStateException ex = assertThrows(IllegalStateException.class, () -> runner.run(null)); |
|||
assertEquals(AdminBootstrapRunner.ERROR_INVALID_PHONE, ex.getMessage()); |
|||
verifyNoInteractions(userRepository); |
|||
verifyNoInteractions(creator); |
|||
} |
|||
|
|||
@Test |
|||
void invalidPhoneFailsAtStartup() { |
|||
for (String bad : new String[]{"123", "1380000000", "138000000012", "1380000000a", " 1380000000a "}) { |
|||
properties.setEnabled("true"); |
|||
properties.setPhone(bad); |
|||
IllegalStateException ex = assertThrows(IllegalStateException.class, () -> runner.run(null)); |
|||
assertEquals(AdminBootstrapRunner.ERROR_INVALID_PHONE, ex.getMessage()); |
|||
} |
|||
verifyNoInteractions(userRepository); |
|||
verifyNoInteractions(creator); |
|||
} |
|||
|
|||
// ── 已有活动 ADMIN ─────────────────────────────────────────
|
|||
|
|||
@Test |
|||
void existingActiveAdminIsSkippedWithoutSideEffects() { |
|||
enableWith(PHONE); |
|||
when(userRepository.countByRoleAndActiveTrue(User.Role.ADMIN)).thenReturn(1L); |
|||
|
|||
runner.run(null); |
|||
|
|||
verify(creator, never()).createAdmin(any(), any()); |
|||
assertTrue(capturedLog().contains("admin bootstrap skipped"), "应输出固定跳过文本"); |
|||
} |
|||
|
|||
// ── 手机号已被占用 ─────────────────────────────────────────
|
|||
|
|||
@Test |
|||
void phoneBelongingToStudentFails() { |
|||
enableWith(PHONE); |
|||
when(userRepository.countByRoleAndActiveTrue(User.Role.ADMIN)).thenReturn(0L); |
|||
when(creator.findByPhone(PHONE)).thenReturn(Optional.of( |
|||
new AdminBootstrapCreator.BootstrapUser(9L, "STUDENT", true, true, true, List.of("STUDENT")))); |
|||
|
|||
IllegalStateException ex = assertThrows(IllegalStateException.class, () -> runner.run(null)); |
|||
assertEquals(AdminBootstrapRunner.ERROR_PHONE_IN_USE, ex.getMessage()); |
|||
verify(creator, never()).createAdmin(any(), any()); |
|||
} |
|||
|
|||
@Test |
|||
void phoneBelongingToCompanyFails() { |
|||
enableWith(PHONE); |
|||
when(userRepository.countByRoleAndActiveTrue(User.Role.ADMIN)).thenReturn(0L); |
|||
when(creator.findByPhone(PHONE)).thenReturn(Optional.of( |
|||
new AdminBootstrapCreator.BootstrapUser(9L, "COMPANY", true, true, true, List.of("COMPANY")))); |
|||
|
|||
assertThrows(IllegalStateException.class, () -> runner.run(null)); |
|||
verify(creator, never()).createAdmin(any(), any()); |
|||
} |
|||
|
|||
@Test |
|||
void phoneBelongingToInactiveUserFailsAndIsNotRevived() { |
|||
enableWith(PHONE); |
|||
when(userRepository.countByRoleAndActiveTrue(User.Role.ADMIN)).thenReturn(0L); |
|||
when(creator.findByPhone(PHONE)).thenReturn(Optional.of( |
|||
new AdminBootstrapCreator.BootstrapUser(9L, "ADMIN", false, true, true, List.of("ADMIN")))); |
|||
|
|||
assertThrows(IllegalStateException.class, () -> runner.run(null)); |
|||
verify(creator, never()).createAdmin(any(), any()); |
|||
} |
|||
|
|||
// ── 正常创建 ───────────────────────────────────────────────
|
|||
|
|||
@Test |
|||
void createsActiveVerifiedAdminWithConsistentRole() { |
|||
enableWith(PHONE); |
|||
when(userRepository.countByRoleAndActiveTrue(User.Role.ADMIN)).thenReturn(0L); |
|||
when(creator.findByPhone(PHONE)).thenReturn(Optional.empty()); |
|||
when(creator.createAdmin(any(), any())).thenReturn(AdminBootstrapCreator.CreateResult.CREATED); |
|||
|
|||
runner.run(null); |
|||
|
|||
// 创建由独立事务组件完成(JdbcTemplate),单测只验证编排参数;
|
|||
// 「users.role / user_roles 一致、active、verified、password_hash 为空」由
|
|||
// AdminBootstrapIntegrationTest 与 AdminBootstrapConcurrencyLoggingTest 在真实库上断言。
|
|||
verify(creator).createAdmin(eq(PHONE), any()); |
|||
verify(userRepository, never()).save(any()); |
|||
} |
|||
|
|||
@Test |
|||
void customNicknameIsApplied() { |
|||
enableWith(PHONE); |
|||
properties.setNickname("运维管理员"); |
|||
when(userRepository.countByRoleAndActiveTrue(User.Role.ADMIN)).thenReturn(0L); |
|||
when(creator.findByPhone(PHONE)).thenReturn(Optional.empty()); |
|||
when(creator.createAdmin(any(), any())).thenReturn(AdminBootstrapCreator.CreateResult.CREATED); |
|||
|
|||
runner.run(null); |
|||
|
|||
verify(creator).createAdmin(PHONE, "运维管理员"); |
|||
} |
|||
|
|||
// ── 日志脱敏 ───────────────────────────────────────────────
|
|||
|
|||
@Test |
|||
void logNeverContainsPhone() { |
|||
enableWith(PHONE); |
|||
when(userRepository.countByRoleAndActiveTrue(User.Role.ADMIN)).thenReturn(0L); |
|||
when(creator.findByPhone(OTHER_PHONE)).thenReturn(Optional.empty()); |
|||
when(creator.createAdmin(any(), any())).thenReturn(AdminBootstrapCreator.CreateResult.CREATED); |
|||
properties.setPhone(OTHER_PHONE); |
|||
|
|||
runner.run(null); |
|||
|
|||
String log = capturedLog(); |
|||
assertThatNotContains(log, OTHER_PHONE); |
|||
assertThatNotContains(log, PHONE); |
|||
} |
|||
|
|||
@Test |
|||
void failureMessagesNeverContainPhone() { |
|||
enableWith(PHONE); |
|||
when(userRepository.countByRoleAndActiveTrue(User.Role.ADMIN)).thenReturn(0L); |
|||
when(creator.findByPhone(PHONE)).thenReturn(Optional.of( |
|||
new AdminBootstrapCreator.BootstrapUser(9L, "STUDENT", true, true, true, List.of("STUDENT")))); |
|||
|
|||
IllegalStateException ex = assertThrows(IllegalStateException.class, () -> runner.run(null)); |
|||
assertThatNotContains(ex.getMessage(), PHONE); |
|||
} |
|||
|
|||
private static void assertThatNotContains(String haystack, String needle) { |
|||
assertFalse(haystack != null && haystack.contains(needle), |
|||
"输出不得包含手机号"); |
|||
} |
|||
} |
|||
@ -0,0 +1,112 @@ |
|||
package com.mydreamjob.auth; |
|||
|
|||
import org.junit.jupiter.api.AfterEach; |
|||
import org.junit.jupiter.api.BeforeEach; |
|||
import org.junit.jupiter.api.Test; |
|||
import org.springframework.beans.factory.annotation.Autowired; |
|||
import org.springframework.boot.test.context.SpringBootTest; |
|||
import org.springframework.boot.test.mock.mockito.MockBean; |
|||
import org.springframework.dao.DataAccessResourceFailureException; |
|||
import org.springframework.data.redis.core.RedisTemplate; |
|||
import org.springframework.jdbc.core.JdbcTemplate; |
|||
import org.springframework.test.context.ActiveProfiles; |
|||
import org.springframework.transaction.PlatformTransactionManager; |
|||
|
|||
import javax.sql.DataSource; |
|||
import java.util.List; |
|||
import java.util.concurrent.atomic.AtomicLong; |
|||
|
|||
import static org.assertj.core.api.Assertions.assertThat; |
|||
import static org.mockito.ArgumentMatchers.any; |
|||
import static org.mockito.ArgumentMatchers.startsWith; |
|||
import static org.mockito.Mockito.doThrow; |
|||
import static org.mockito.Mockito.spy; |
|||
|
|||
/** |
|||
* S03 定向整改:{@code users} 与 {@code user_roles} 必须**同事务**提交 —— |
|||
* 角色写入失败时用户创建整体回滚,不得留下「有用户、无角色」的半个管理员。 |
|||
*/ |
|||
@SpringBootTest |
|||
@ActiveProfiles("integration-test") |
|||
class AdminBootstrapTransactionTest { |
|||
|
|||
private static final AtomicLong PHONE_SEQ = new AtomicLong(8600); |
|||
|
|||
@MockBean |
|||
private RedisTemplate<String, Object> redisTemplate; |
|||
|
|||
@Autowired |
|||
private DataSource dataSource; |
|||
|
|||
@Autowired |
|||
private PlatformTransactionManager transactionManager; |
|||
|
|||
@Autowired |
|||
private JdbcTemplate jdbcTemplate; |
|||
|
|||
private String phone; |
|||
|
|||
@BeforeEach |
|||
void setUp() { |
|||
phone = "13" + String.format("%09d", PHONE_SEQ.incrementAndGet()); |
|||
} |
|||
|
|||
@AfterEach |
|||
void tearDown() { |
|||
jdbcTemplate.update("DELETE FROM user_roles WHERE user_id IN (SELECT id FROM users WHERE phone = ?)", phone); |
|||
jdbcTemplate.update("DELETE FROM users WHERE phone = ?", phone); |
|||
} |
|||
|
|||
private int userRows() { |
|||
Integer n = jdbcTemplate.queryForObject( |
|||
"SELECT COUNT(*) FROM users WHERE phone = ?", Integer.class, phone); |
|||
return n == null ? 0 : n; |
|||
} |
|||
|
|||
private int roleRows() { |
|||
Integer n = jdbcTemplate.queryForObject( |
|||
"SELECT COUNT(*) FROM user_roles ur JOIN users u ON u.id = ur.user_id WHERE u.phone = ?", |
|||
Integer.class, phone); |
|||
return n == null ? 0 : n; |
|||
} |
|||
|
|||
@Test |
|||
void successfulCreateWritesUserAndRoleInSameTransaction() { |
|||
AdminBootstrapCreator creator = new AdminBootstrapCreator(jdbcTemplate, transactionManager); |
|||
|
|||
assertThat(creator.createAdmin(phone, "事务管理员")) |
|||
.isEqualTo(AdminBootstrapCreator.CreateResult.CREATED); |
|||
assertThat(userRows()).as("用户恰好一行").isEqualTo(1); |
|||
assertThat(roleRows()).as("user_roles 恰好一行").isEqualTo(1); |
|||
} |
|||
|
|||
@Test |
|||
void roleInsertFailureRollsBackUser() { |
|||
JdbcTemplate failing = spy(new JdbcTemplate(dataSource)); |
|||
// 让 user_roles 的写入失败,users 的写入成功 —— 验证两者同事务。
|
|||
doThrow(new DataAccessResourceFailureException("simulated role insert failure")) |
|||
.when(failing).update(startsWith("INSERT INTO user_roles"), any(Object[].class)); |
|||
|
|||
AdminBootstrapCreator creator = new AdminBootstrapCreator(failing, transactionManager); |
|||
|
|||
assertThat(creator.createAdmin(phone, "回滚管理员")) |
|||
.as("角色写入失败必须返回 CONFLICT,而不是抛出携带原始信息的异常") |
|||
.isEqualTo(AdminBootstrapCreator.CreateResult.CONFLICT); |
|||
|
|||
assertThat(userRows()).as("users 必须随角色失败整体回滚").isZero(); |
|||
assertThat(roleRows()).isZero(); |
|||
} |
|||
|
|||
@Test |
|||
void concurrentCreateLeavesExactlyOneUserAndOneRole() { |
|||
AdminBootstrapCreator creator = new AdminBootstrapCreator(jdbcTemplate, transactionManager); |
|||
|
|||
AdminBootstrapCreator.CreateResult first = creator.createAdmin(phone, "并发甲"); |
|||
AdminBootstrapCreator.CreateResult second = creator.createAdmin(phone, "并发乙"); |
|||
|
|||
assertThat(List.of(first, second)) |
|||
.as("第二次同号创建必须被识别为冲突").contains(AdminBootstrapCreator.CreateResult.CONFLICT); |
|||
assertThat(userRows()).as("并发后用户恰好一行").isEqualTo(1); |
|||
assertThat(roleRows()).as("并发后 ADMIN 角色恰好一行").isEqualTo(1); |
|||
} |
|||
} |
|||
@ -0,0 +1,223 @@ |
|||
package com.mydreamjob.auth; |
|||
|
|||
import org.junit.jupiter.api.AfterEach; |
|||
import org.junit.jupiter.api.BeforeEach; |
|||
import org.junit.jupiter.api.Test; |
|||
import org.springframework.beans.factory.annotation.Autowired; |
|||
import org.springframework.boot.test.context.SpringBootTest; |
|||
import org.springframework.boot.test.mock.mockito.MockBean; |
|||
import org.springframework.core.io.ClassPathResource; |
|||
import org.springframework.data.redis.core.RedisTemplate; |
|||
import org.springframework.jdbc.core.JdbcTemplate; |
|||
import org.springframework.test.context.ActiveProfiles; |
|||
|
|||
import java.io.IOException; |
|||
import java.nio.charset.StandardCharsets; |
|||
import java.util.ArrayList; |
|||
import java.util.List; |
|||
import java.util.Map; |
|||
|
|||
import static org.assertj.core.api.Assertions.assertThat; |
|||
|
|||
/** |
|||
* S03-A {@code V43__disable_legacy_seed_accounts.sql} 的行为验证。 |
|||
* |
|||
* <p>直接读取并执行迁移文件中的语句,验证:完整指纹命中、任一单字段偏差不命中、 |
|||
* 非固定 ID 也能命中、重复执行幂等、关联数据保留、迁移文件本身的约束。</p> |
|||
* |
|||
* <p>V1_1 的种子账号仍占用原始手机号(唯一约束),因此用例开始前把它们移到哨兵指纹, |
|||
* 结束时原样恢复;用例自身插入的行按昵称前缀清理。</p> |
|||
*/ |
|||
@SpringBootTest |
|||
@ActiveProfiles("integration-test") |
|||
class LegacySeedAccountDisableMigrationTest { |
|||
|
|||
private static final String HASH = "$2a$10$N.zmdr9k7uOCQb376NoUnuTJ8iAt6Z5EHsM8lE9lBOsl7iKTVKIUi"; |
|||
private static final String TAG = "V43TEST_"; |
|||
private static final List<String> SEED_PHONES = List.of("13800000001", "13800000002", "13800000003"); |
|||
|
|||
@MockBean |
|||
private RedisTemplate<String, Object> redisTemplate; |
|||
|
|||
@Autowired |
|||
private JdbcTemplate jdbcTemplate; |
|||
|
|||
private String migrationSql; |
|||
private final List<Map<String, Object>> movedSeedRows = new ArrayList<>(); |
|||
|
|||
@BeforeEach |
|||
void setUp() throws IOException { |
|||
ClassPathResource res = new ClassPathResource("db/migration/V43__disable_legacy_seed_accounts.sql"); |
|||
migrationSql = new String(res.getInputStream().readAllBytes(), StandardCharsets.UTF_8); |
|||
|
|||
cleanupOwnRows(); |
|||
moveSeedRowsAside(); |
|||
} |
|||
|
|||
@AfterEach |
|||
void tearDown() { |
|||
cleanupOwnRows(); |
|||
restoreSeedRows(); |
|||
} |
|||
|
|||
private String seedPhoneList() { |
|||
return SEED_PHONES.stream().map(p -> "'" + p + "'").reduce((a, b) -> a + "," + b).orElse("''"); |
|||
} |
|||
|
|||
/** 把 V1_1 种子行移到哨兵指纹,腾出原始手机号与邮箱。 */ |
|||
private void moveSeedRowsAside() { |
|||
movedSeedRows.clear(); |
|||
movedSeedRows.addAll(jdbcTemplate.queryForList( |
|||
"SELECT id, phone, email FROM users WHERE phone IN (" + seedPhoneList() + ")")); |
|||
jdbcTemplate.update( |
|||
"UPDATE users SET phone = CONCAT('SEEDMVD', id), email = CONCAT('seedmvd', id, '@example.invalid') " |
|||
+ "WHERE phone IN (" + seedPhoneList() + ")"); |
|||
} |
|||
|
|||
/** 把种子行恢复为最初的位置(不改动它们的 is_active 等状态)。 */ |
|||
private void restoreSeedRows() { |
|||
for (Map<String, Object> row : movedSeedRows) { |
|||
jdbcTemplate.update("UPDATE users SET phone = ?, email = ? WHERE id = ?", |
|||
row.get("phone"), row.get("email"), row.get("id")); |
|||
} |
|||
movedSeedRows.clear(); |
|||
} |
|||
|
|||
private void cleanupOwnRows() { |
|||
jdbcTemplate.update("DELETE FROM user_roles WHERE user_id IN (SELECT id FROM users WHERE nickname LIKE ?)", TAG + "%"); |
|||
jdbcTemplate.update("DELETE FROM users WHERE nickname LIKE ?", TAG + "%"); |
|||
} |
|||
|
|||
/** |
|||
* 执行迁移文件中的全部 UPDATE 语句。 |
|||
* |
|||
* <p>必须先逐行剥离 {@code --} 注释,否则首个语句块会以注释开头而被整体跳过 |
|||
* (那样"不命中"类断言会假通过)。</p> |
|||
*/ |
|||
private void runMigration() { |
|||
StringBuilder stripped = new StringBuilder(); |
|||
for (String line : migrationSql.split("\n")) { |
|||
if (line.trim().startsWith("--")) { |
|||
continue; |
|||
} |
|||
stripped.append(line).append('\n'); |
|||
} |
|||
for (String stmt : stripped.toString().split(";")) { |
|||
String s = stmt.trim(); |
|||
if (s.isEmpty()) { |
|||
continue; |
|||
} |
|||
jdbcTemplate.update(s); |
|||
} |
|||
} |
|||
|
|||
private long insertUser(String phone, String email, String hash, String role, int active, String nickname) { |
|||
jdbcTemplate.update( |
|||
"INSERT INTO users (phone, email, nickname, password_hash, role, is_active, is_verified) " |
|||
+ "VALUES (?, ?, ?, ?, ?, ?, 1)", |
|||
phone, email, nickname, hash, role, active); |
|||
Long id = jdbcTemplate.queryForObject("SELECT id FROM users WHERE phone = ?", Long.class, phone); |
|||
jdbcTemplate.update("INSERT INTO user_roles (user_id, role) VALUES (?, ?)", id, role); |
|||
return id; |
|||
} |
|||
|
|||
private int activeOf(long id) { |
|||
Integer v = jdbcTemplate.queryForObject("SELECT is_active FROM users WHERE id = ?", Integer.class, id); |
|||
return v == null ? -1 : v; |
|||
} |
|||
|
|||
// ── 完整指纹命中 ────────────────────────────────────────────
|
|||
|
|||
@Test |
|||
void fullFingerprintMatchIsDisabled() { |
|||
long admin = insertUser("13800000001", "admin@mydreamjob.com", HASH, "ADMIN", 1, TAG + "admin"); |
|||
long student = insertUser("13800000002", "student@mydreamjob.com", HASH, "STUDENT", 1, TAG + "student"); |
|||
long company = insertUser("13800000003", "company@mydreamjob.com", HASH, "COMPANY", 1, TAG + "company"); |
|||
|
|||
runMigration(); |
|||
|
|||
assertThat(activeOf(admin)).as("ADMIN 原始指纹应被禁用").isZero(); |
|||
assertThat(activeOf(student)).as("STUDENT 原始指纹应被禁用").isZero(); |
|||
assertThat(activeOf(company)).as("COMPANY 原始指纹应被禁用").isZero(); |
|||
} |
|||
|
|||
@Test |
|||
void hitsRegardlessOfNumericId() { |
|||
long id = insertUser("13800000001", "admin@mydreamjob.com", HASH, "ADMIN", 1, TAG + "nonfixed"); |
|||
assertThat(id).as("用例应使用 1/2/3 之外的 id").isNotIn(1L, 2L, 3L); |
|||
|
|||
runMigration(); |
|||
assertThat(activeOf(id)).isZero(); |
|||
} |
|||
|
|||
// ── 任一单字段偏差不命中 ────────────────────────────────────
|
|||
|
|||
@Test |
|||
void singleFieldDeviationIsNotDisabled() { |
|||
long phoneChanged = insertUser("13800000099", "admin@mydreamjob.com", HASH, "ADMIN", 1, TAG + "phone"); |
|||
long emailChanged = insertUser("13800000001", "ops@mydreamjob.com", HASH, "ADMIN", 1, TAG + "email"); |
|||
long hashChanged = insertUser("13800000002", "student@mydreamjob.com", "$2a$10$DIFFERENTHASHVALUE", "STUDENT", 1, TAG + "hash"); |
|||
long roleChanged = insertUser("13800000003", "company@mydreamjob.com", HASH, "STUDENT", 1, TAG + "role"); |
|||
|
|||
runMigration(); |
|||
|
|||
assertThat(activeOf(phoneChanged)).as("改过手机号不应被禁用").isEqualTo(1); |
|||
assertThat(activeOf(emailChanged)).as("改过邮箱不应被禁用").isEqualTo(1); |
|||
assertThat(activeOf(hashChanged)).as("改过口令哈希不应被禁用").isEqualTo(1); |
|||
assertThat(activeOf(roleChanged)).as("改过角色不应被禁用").isEqualTo(1); |
|||
} |
|||
|
|||
@Test |
|||
void alreadyInactiveAccountIsNotMatched() { |
|||
long id = insertUser("13800000001", "admin@mydreamjob.com", HASH, "ADMIN", 0, TAG + "inactive"); |
|||
runMigration(); |
|||
assertThat(activeOf(id)).as("条件含 is_active = 1,已禁用行不再命中").isZero(); |
|||
} |
|||
|
|||
// ── 重复执行 ────────────────────────────────────────────────
|
|||
|
|||
@Test |
|||
void repeatedExecutionIsStable() { |
|||
long id = insertUser("13800000001", "admin@mydreamjob.com", HASH, "ADMIN", 1, TAG + "repeat"); |
|||
runMigration(); |
|||
assertThat(activeOf(id)).isZero(); |
|||
|
|||
runMigration(); |
|||
runMigration(); |
|||
|
|||
assertThat(activeOf(id)).as("重复执行后仍为 disabled").isZero(); |
|||
Integer rows = jdbcTemplate.queryForObject( |
|||
"SELECT COUNT(*) FROM users WHERE phone = '13800000001' AND nickname = ?", Integer.class, TAG + "repeat"); |
|||
assertThat(rows).as("不得产生或删除记录").isEqualTo(1); |
|||
} |
|||
|
|||
// ── 关联数据保留 ────────────────────────────────────────────
|
|||
|
|||
@Test |
|||
void relatedRowsArePreserved() { |
|||
long id = insertUser("13800000003", "company@mydreamjob.com", HASH, "COMPANY", 1, TAG + "assoc"); |
|||
|
|||
runMigration(); |
|||
|
|||
Integer stillThere = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM users WHERE id = ?", Integer.class, id); |
|||
assertThat(stillThere).as("迁移不得删除用户记录").isEqualTo(1); |
|||
List<Map<String, Object>> roles = jdbcTemplate.queryForList( |
|||
"SELECT role FROM user_roles WHERE user_id = ?", id); |
|||
assertThat(roles).as("迁移不得删除 user_roles 记录").hasSize(1); |
|||
assertThat(roles.get(0).get("role")).isEqualTo("COMPANY"); |
|||
} |
|||
|
|||
// ── 迁移文件本身的约束 ──────────────────────────────────────
|
|||
|
|||
@Test |
|||
void migrationFileOnlyUpdatesIsActiveWithFiveFingerprints() { |
|||
assertThat(migrationSql).as("只允许 UPDATE users").doesNotContainIgnoringCase("DELETE FROM"); |
|||
assertThat(migrationSql).doesNotContainIgnoringCase("DROP "); |
|||
assertThat(migrationSql).doesNotContainIgnoringCase("TRUNCATE"); |
|||
for (String clause : new String[]{"phone =", "email =", "password_hash =", "role =", "is_active = 1"}) { |
|||
assertThat(migrationSql).as("匹配条件必须包含 " + clause).contains(clause); |
|||
} |
|||
int updates = migrationSql.split("(?i)UPDATE users").length - 1; |
|||
assertThat(updates).as("ADMIN/STUDENT/COMPANY 三条").isEqualTo(3); |
|||
} |
|||
} |
|||
@ -0,0 +1,317 @@ |
|||
package com.mydreamjob.config; |
|||
|
|||
import com.mydreamjob.auth.User; |
|||
import com.mydreamjob.auth.support.TestAuthTokens; |
|||
import com.mydreamjob.auth.UserRepository; |
|||
import org.junit.jupiter.api.AfterEach; |
|||
import org.junit.jupiter.api.BeforeEach; |
|||
import org.junit.jupiter.api.Test; |
|||
import org.springframework.beans.factory.annotation.Autowired; |
|||
import org.springframework.beans.factory.annotation.Value; |
|||
import org.springframework.boot.test.context.SpringBootTest; |
|||
import org.springframework.boot.test.mock.mockito.MockBean; |
|||
import org.springframework.boot.test.web.server.LocalManagementPort; |
|||
import org.springframework.boot.test.web.server.LocalServerPort; |
|||
import org.springframework.data.redis.core.RedisTemplate; |
|||
import org.springframework.data.redis.core.ValueOperations; |
|||
import org.springframework.test.context.ActiveProfiles; |
|||
|
|||
import java.net.URI; |
|||
import java.net.http.HttpClient; |
|||
import java.net.http.HttpRequest; |
|||
import java.net.http.HttpResponse; |
|||
import java.time.Duration; |
|||
import java.util.Map; |
|||
import java.util.Set; |
|||
import java.util.concurrent.atomic.AtomicLong; |
|||
|
|||
import static org.assertj.core.api.Assertions.assertThat; |
|||
import static org.mockito.ArgumentMatchers.anyString; |
|||
import static org.mockito.Mockito.mock; |
|||
import static org.mockito.Mockito.when; |
|||
|
|||
/** |
|||
* S03-C Actuator 隔离:业务端口不提供指标,管理端口只绑定配置地址,健康探针最小化。 |
|||
* |
|||
* <p>真实启动两个端口(业务随机端口 + 管理随机端口),用真实 HTTP 验证。</p> |
|||
*/ |
|||
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, |
|||
properties = { |
|||
"management.server.port=0", |
|||
"management.server.address=127.0.0.1" |
|||
}) |
|||
@ActiveProfiles("integration-test") |
|||
class ActuatorIsolationIntegrationTest { |
|||
|
|||
private static final AtomicLong PHONE_SEQ = new AtomicLong(8700); |
|||
|
|||
@MockBean |
|||
private RedisTemplate<String, Object> redisTemplate; |
|||
|
|||
@LocalServerPort |
|||
private int businessPort; |
|||
|
|||
@LocalManagementPort |
|||
private int managementPort; |
|||
|
|||
@Value("${management.server.address}") |
|||
private String managementAddress; |
|||
|
|||
@Value("${management.endpoint.health.show-details}") |
|||
private String showDetails; |
|||
|
|||
@Value("${management.endpoint.health.show-components}") |
|||
private String showComponents; |
|||
|
|||
@Autowired |
|||
private UserRepository userRepository; |
|||
|
|||
private final HttpClient client = HttpClient.newBuilder() |
|||
.connectTimeout(Duration.ofSeconds(5)).build(); |
|||
private Long studentId; |
|||
private Long companyId; |
|||
private Long adminId; |
|||
|
|||
@BeforeEach |
|||
@SuppressWarnings("unchecked") |
|||
void setUp() { |
|||
ValueOperations<String, Object> ops = mock(ValueOperations.class); |
|||
when(redisTemplate.opsForValue()).thenReturn(ops); |
|||
when(redisTemplate.hasKey(anyString())).thenReturn(false); |
|||
|
|||
studentId = createUser(User.Role.STUDENT); |
|||
companyId = createUser(User.Role.COMPANY); |
|||
adminId = createUser(User.Role.ADMIN); |
|||
} |
|||
|
|||
@AfterEach |
|||
void tearDown() { |
|||
for (Long id : new Long[]{studentId, companyId, adminId}) { |
|||
if (id != null) { |
|||
userRepository.deleteById(id); |
|||
} |
|||
} |
|||
} |
|||
|
|||
private Long createUser(User.Role role) { |
|||
return userRepository.save(User.builder() |
|||
.phone("16" + String.format("%09d", PHONE_SEQ.incrementAndGet())) |
|||
.nickname("S03 actuator") |
|||
.role(role).roles(Set.of(role)) |
|||
.active(true).verified(true).build()).getId(); |
|||
} |
|||
|
|||
private int get(int port, String path, String token) { |
|||
try { |
|||
HttpRequest.Builder b = HttpRequest.newBuilder() |
|||
.uri(URI.create("http://127.0.0.1:" + port + path)) |
|||
.timeout(Duration.ofSeconds(10)).GET(); |
|||
if (token != null) { |
|||
b.header("Authorization", "Bearer " + token); |
|||
} |
|||
return client.send(b.build(), HttpResponse.BodyHandlers.ofString()).statusCode(); |
|||
} catch (Exception e) { |
|||
return -1; |
|||
} |
|||
} |
|||
|
|||
private String body(int port, String path) { |
|||
return rawGet(port, path, null).body(); |
|||
} |
|||
|
|||
/** 一次真实 HTTP GET 的结果;连接层失败记为 status=-1,让断言明确失败而非抛异常。 */ |
|||
private record Probe(int status, String body, String contentType) { |
|||
} |
|||
|
|||
private Probe rawGet(int port, String path, String token) { |
|||
try { |
|||
HttpRequest.Builder b = HttpRequest.newBuilder() |
|||
.uri(URI.create("http://127.0.0.1:" + port + path)) |
|||
.timeout(Duration.ofSeconds(10)).GET(); |
|||
if (token != null) { |
|||
b.header("Authorization", "Bearer " + token); |
|||
} |
|||
HttpResponse<String> r = client.send(b.build(), HttpResponse.BodyHandlers.ofString()); |
|||
return new Probe(r.statusCode(), r.body(), |
|||
r.headers().firstValue("Content-Type").orElse("")); |
|||
} catch (Exception e) { |
|||
return new Probe(-1, "", ""); |
|||
} |
|||
} |
|||
|
|||
// ── 业务端口 ───────────────────────────────────────────────
|
|||
|
|||
@Test |
|||
void businessPortDoesNotExposeActuatorEndpoints() { |
|||
for (String path : new String[]{"/actuator/metrics", "/actuator/prometheus", |
|||
"/actuator/health", "/actuator/info", "/actuator"}) { |
|||
assertThat(get(businessPort, path, null)) |
|||
.as("业务端口不得提供 " + path).isEqualTo(404); |
|||
} |
|||
} |
|||
|
|||
@Test |
|||
void businessTokensCannotRevealMetricsOnBusinessPort() { |
|||
String[] tokens = { |
|||
TestAuthTokens.bearer(studentId, "STUDENT"), |
|||
TestAuthTokens.bearer(companyId, "COMPANY"), |
|||
TestAuthTokens.bearer(adminId, "ADMIN"), |
|||
}; |
|||
for (String token : tokens) { |
|||
for (String path : new String[]{"/actuator/metrics", "/actuator/prometheus"}) { |
|||
assertThat(get(businessPort, path, token)) |
|||
.as("业务 JWT 不得让指标出现在业务端口:" + path).isEqualTo(404); |
|||
} |
|||
} |
|||
} |
|||
|
|||
@Test |
|||
void businessHealthProbeStaysMinimal() throws Exception { |
|||
// 业务健康检查走自定义 /health 控制器,只含最小状态。
|
|||
// 按**字段名**断言,而不是对原始 JSON 做子串匹配 —— 随机 requestId 可能恰好包含
|
|||
// "db" 之类的子串,会造成假失败。
|
|||
String body = body(businessPort, "/api/v1/health"); |
|||
var data = new com.fasterxml.jackson.databind.ObjectMapper().readTree(body).path("data"); |
|||
|
|||
assertThat(data.path("status").asText()).isNotBlank(); |
|||
var fields = new java.util.ArrayList<String>(); |
|||
data.fieldNames().forEachRemaining(fields::add); |
|||
for (String forbidden : new String[]{"db", "redis", "rabbit", "rabbitmq", "diskSpace", |
|||
"hikari", "connectionPool", "components", "details"}) { |
|||
assertThat(fields).as("业务健康响应不得含字段 " + forbidden).doesNotContain(forbidden); |
|||
} |
|||
} |
|||
|
|||
// ── 管理端口 ───────────────────────────────────────────────
|
|||
|
|||
@Test |
|||
void managementPortServesHealthAndMetrics() throws Exception { |
|||
String token = TestAuthTokens.bearer(adminId, "ADMIN"); |
|||
|
|||
// health:允许 200,或因外围依赖不可用而 503;但必须是合法 JSON 且含 status。
|
|||
Probe health = rawGet(managementPort, "/actuator/health", null); |
|||
assertThat(health.status()).as("health 必须是 200 或 503(实测 " + health.status() + ")") |
|||
.isIn(200, 503); |
|||
var healthJson = new com.fasterxml.jackson.databind.ObjectMapper().readTree(health.body()); |
|||
assertThat(healthJson.has("status")).as("health 响应必须含 status 字段").isTrue(); |
|||
|
|||
// metrics:必须 200 且返回 names 列表;-1/401/403/404/500 均不算成功。
|
|||
Probe metrics = rawGet(managementPort, "/actuator/metrics", token); |
|||
assertThat(metrics.status()).as("metrics 必须 HTTP 200(实测 " + metrics.status() + ")") |
|||
.isEqualTo(200); |
|||
var metricsJson = new com.fasterxml.jackson.databind.ObjectMapper().readTree(metrics.body()); |
|||
assertThat(metricsJson.has("names")).as("metrics 响应必须包含 names").isTrue(); |
|||
assertThat(metricsJson.get("names").isArray()).as("names 必须是数组").isTrue(); |
|||
|
|||
// prometheus:必须 200,Content-Type 为 Prometheus 文本格式,正文含稳定指标名。
|
|||
Probe prom = rawGet(managementPort, "/actuator/prometheus", token); |
|||
assertThat(prom.status()).as("prometheus 必须 HTTP 200(实测 " + prom.status() + ")") |
|||
.isEqualTo(200); |
|||
assertThat(prom.contentType()).as("prometheus Content-Type 必须为文本格式(实测 " + prom.contentType() + ")") |
|||
.containsIgnoringCase("text/plain"); |
|||
assertThat(prom.body()).as("prometheus 正文必须含稳定指标名") |
|||
.contains("jvm_memory_used_bytes"); |
|||
} |
|||
|
|||
@Test |
|||
void managementPortIsIsolatedByNetworkNotByBusinessCredentials() { |
|||
// 设计决策:管理端口的运维边界是**网络隔离**(默认只绑定 127.0.0.1),
|
|||
// 而不是业务 JWT 角色。因此指标在管理端口无需凭据即可读取,
|
|||
// 而在业务端口上同一路径根本不存在。
|
|||
assertThat(rawGet(managementPort, "/actuator/metrics", null).status()) |
|||
.as("管理端口上 metrics 由网络隔离保护,无需业务凭据").isEqualTo(200); |
|||
assertThat(get(businessPort, "/actuator/metrics", null)) |
|||
.as("业务端口不得提供同一端点").isEqualTo(404); |
|||
} |
|||
|
|||
@Test |
|||
void managementHealthReturnsMinimalStatusOnly() { |
|||
String body = body(managementPort, "/actuator/health"); |
|||
|
|||
assertThat(body).as("健康响应必须含 status 字段").contains("status"); |
|||
// 组件与细节一律不暴露:show-details=never / show-components=never 的实际效果
|
|||
assertThat(body).as("不得返回组件明细") |
|||
.doesNotContain("components") |
|||
.doesNotContain("diskSpace") |
|||
.doesNotContain("hikari") |
|||
.doesNotContain("connectionPool") |
|||
.doesNotContain("mydreamjob-mysql"); |
|||
} |
|||
|
|||
@Test |
|||
void onlyWhitelistedEndpointsAreExposed() { |
|||
// 非白名单端点不得可用:既可能因未暴露而 404,也可能被安全层先行拦为 401,
|
|||
// 两者都证明「不可读」。
|
|||
for (String path : new String[]{"/actuator/env", "/actuator/beans", |
|||
"/actuator/configprops", "/actuator/heapdump", "/actuator/threaddump"}) { |
|||
int code = get(managementPort, path, null); |
|||
assertThat(code).as(path + " 不得可读(实测 " + code + ")").isIn(401, 404); |
|||
} |
|||
} |
|||
|
|||
// ── 配置断言 ───────────────────────────────────────────────
|
|||
|
|||
@Test |
|||
void managementBindsLoopbackAndHealthDetailsAreNever() { |
|||
assertThat(managementAddress).as("管理端口默认只绑定回环地址").isEqualTo("127.0.0.1"); |
|||
assertThat(showDetails).isEqualTo("never"); |
|||
assertThat(showComponents).isEqualTo("never"); |
|||
} |
|||
|
|||
@Test |
|||
void managementPortDiffersFromBusinessPort() { |
|||
assertThat(managementPort).as("管理端口必须与业务端口分离").isNotEqualTo(businessPort); |
|||
} |
|||
|
|||
// ── S03 整改复验:业务端口 /actuator/** 任何身份一律 404 ──────────
|
|||
|
|||
@Test |
|||
void businessActuatorPathsReturn404ForEveryIdentity() { |
|||
String[] paths = { |
|||
"/actuator", "/actuator/", "/actuator/health", "/actuator/health/liveness", |
|||
"/actuator/info", "/actuator/metrics", "/actuator/metrics/jvm.memory.used", |
|||
"/actuator/prometheus", "/actuator/env", "/actuator/beans", |
|||
"/actuator/configprops", "/actuator/heapdump", "/actuator/threaddump", |
|||
"/actuator/unknown" |
|||
}; |
|||
String[] identities = { |
|||
null, |
|||
TestAuthTokens.bearer(studentId, "STUDENT"), |
|||
TestAuthTokens.bearer(companyId, "COMPANY"), |
|||
TestAuthTokens.bearer(adminId, "ADMIN"), |
|||
"Bearer invalid.garbage.token.value", |
|||
"Bearer " + TestAuthTokens.refreshToken(studentId, "STUDENT"), |
|||
}; |
|||
for (String path : paths) { |
|||
for (String auth : identities) { |
|||
Probe p = rawGet(businessPort, path, auth); |
|||
assertThat(p.status()) |
|||
.as("8080 %s(身份=%s)必须 404,不得出现 401/403/500", |
|||
path, auth == null ? "anonymous" : auth.substring(0, Math.min(11, auth.length())) + "...") |
|||
.isEqualTo(404); |
|||
assertNoLeak(p.body()); |
|||
} |
|||
} |
|||
} |
|||
|
|||
/** 递归扫描响应:任何异常/实现细节/路径/敏感信息都不得出现。 */ |
|||
private static void assertNoLeak(String body) { |
|||
String normalized = body == null ? "" : body; |
|||
assertThat(normalized).as("不得包含异常类名/静态资源提示/框架包名") |
|||
.doesNotContain("NoResourceFoundException", "No static resource", "org.springframework", |
|||
"classpath", "stackTrace", "exception", "handler"); |
|||
assertThat(normalized).as("不得包含本地路径").doesNotContain("C:", "\\Users", "\\Desktop"); |
|||
assertThat(normalized.toLowerCase()).as("不得包含 Token/凭据/数据库信息") |
|||
.doesNotContain("token", "password", "jdbc"); |
|||
assertThat(normalized).as("不得包含 11 位连续数字(手机号)").doesNotMatch("\\d{11}"); |
|||
} |
|||
|
|||
@Test |
|||
void managementUnexposedEndpointsReturn404() { |
|||
for (String path : new String[]{"/actuator/env", "/actuator/beans", "/actuator/configprops", |
|||
"/actuator/heapdump", "/actuator/threaddump", "/actuator/unknown"}) { |
|||
int code = get(managementPort, path, null); |
|||
assertThat(code).as("8081 %s 必须 404(未暴露端点,不得 401/200/403/500)", path).isEqualTo(404); |
|||
} |
|||
} |
|||
} |
|||
Loading…
Reference in new issue