From cb3bc57fab549137e3785f5d50bde86ce8ef5731 Mon Sep 17 00:00:00 2001 From: Zhang Shun Date: Tue, 15 Sep 2026 11:48:02 +0800 Subject: [PATCH] feat: harden production bootstrap and resume privacy --- docs/backend-development-roadmap.md | 1 + docs/frontend-integration.md | 24 + docs/openapi-rc.json | 34 + docs/phase1-scope-and-acceptance.md | 1 + docs/privacy-settings-api.md | 29 +- docs/resume-api.md | 39 ++ ...3-independent-mysql-acceptance-20260915.md | 167 +++++ ...tion-security-and-resume-privacy-design.md | 167 +++++ ...sume-privacy-implementation-instruction.md | 270 ++++++++ docs/verify_openapi.py | 34 +- mydreamjob-server/pom.xml | 5 + .../application/ApplicationController.java | 29 + .../CompanyResumeAccessPolicy.java | 132 ++++ .../application/CompanyResumeService.java | 72 +++ .../application/dto/CompanyResumeFile.java | 10 + .../auth/AdminBootstrapCreator.java | 105 +++ .../mydreamjob/auth/AdminBootstrapRunner.java | 117 ++++ .../exception/GlobalExceptionHandler.java | 12 + .../config/AdminBootstrapProperties.java | 28 + .../com/mydreamjob/config/SecurityConfig.java | 11 +- .../privacy/PrivacySettingsService.java | 49 +- .../src/main/resources/application.yml | 25 +- .../V43__disable_legacy_seed_accounts.sql | 40 ++ .../CompanyResumeAccessPolicyTest.java | 320 +++++++++ .../CompanyResumeDownloadIntegrationTest.java | 606 ++++++++++++++++++ .../AdminBootstrapConcurrencyLoggingTest.java | 299 +++++++++ .../AdminBootstrapDisabledByDefaultTest.java | 41 ++ .../auth/AdminBootstrapIntegrationTest.java | 235 +++++++ .../auth/AdminBootstrapRunnerTest.java | 237 +++++++ .../auth/AdminBootstrapTransactionTest.java | 112 ++++ ...LegacySeedAccountDisableMigrationTest.java | 223 +++++++ .../auth/support/TestAuthTokens.java | 15 +- .../ActuatorIsolationIntegrationTest.java | 317 +++++++++ .../privacy/PrivacySettingsServiceTest.java | 90 +-- 34 files changed, 3771 insertions(+), 125 deletions(-) create mode 100644 docs/s03-independent-mysql-acceptance-20260915.md create mode 100644 docs/s03-production-security-and-resume-privacy-design.md create mode 100644 docs/s03-production-security-and-resume-privacy-implementation-instruction.md create mode 100644 mydreamjob-server/src/main/java/com/mydreamjob/application/CompanyResumeAccessPolicy.java create mode 100644 mydreamjob-server/src/main/java/com/mydreamjob/application/CompanyResumeService.java create mode 100644 mydreamjob-server/src/main/java/com/mydreamjob/application/dto/CompanyResumeFile.java create mode 100644 mydreamjob-server/src/main/java/com/mydreamjob/auth/AdminBootstrapCreator.java create mode 100644 mydreamjob-server/src/main/java/com/mydreamjob/auth/AdminBootstrapRunner.java create mode 100644 mydreamjob-server/src/main/java/com/mydreamjob/config/AdminBootstrapProperties.java create mode 100644 mydreamjob-server/src/main/resources/db/migration/V43__disable_legacy_seed_accounts.sql create mode 100644 mydreamjob-server/src/test/java/com/mydreamjob/application/CompanyResumeAccessPolicyTest.java create mode 100644 mydreamjob-server/src/test/java/com/mydreamjob/application/CompanyResumeDownloadIntegrationTest.java create mode 100644 mydreamjob-server/src/test/java/com/mydreamjob/auth/AdminBootstrapConcurrencyLoggingTest.java create mode 100644 mydreamjob-server/src/test/java/com/mydreamjob/auth/AdminBootstrapDisabledByDefaultTest.java create mode 100644 mydreamjob-server/src/test/java/com/mydreamjob/auth/AdminBootstrapIntegrationTest.java create mode 100644 mydreamjob-server/src/test/java/com/mydreamjob/auth/AdminBootstrapRunnerTest.java create mode 100644 mydreamjob-server/src/test/java/com/mydreamjob/auth/AdminBootstrapTransactionTest.java create mode 100644 mydreamjob-server/src/test/java/com/mydreamjob/auth/LegacySeedAccountDisableMigrationTest.java create mode 100644 mydreamjob-server/src/test/java/com/mydreamjob/config/ActuatorIsolationIntegrationTest.java diff --git a/docs/backend-development-roadmap.md b/docs/backend-development-roadmap.md index daadd23..ca6a494 100644 --- a/docs/backend-development-roadmap.md +++ b/docs/backend-development-roadmap.md @@ -13,6 +13,7 @@ | 消息撤回与会话一致性 | N07 定向整改完成并通过自动化与干净构建,待独立代码复验与真实 MySQL/Docker 验收 | 两分钟窗口、发送者权限、正文覆盖、幂等并发、DIRECT/邀约隐私均已锁定 | | S01 认证安全整改 | 独立代码复验与真实 MySQL/Docker 黑盒全部通过(`b5084fd`) | 保持验证码受控取码、双开关与 `/jobs` 认证边界不变 | | S02 Access/Refresh Token 隔离 | 已实现并完成自动化验证与干净构建,待独立代码复验及真实 MySQL/Docker 黑盒验收 | 类型隔离、独立密钥、轮换、重放处置、会话吊销已锁定;OpenAPI 175/213 | +| S03 生产配置与简历隐私 | 已实现并完成自动化验证与干净构建,待独立代码复验及真实 MySQL/Docker 黑盒验收 | V43 禁用原始指纹种子账号;管理员初始化默认关闭且幂等;企业简历下载按隐私决策表授权;Actuator 隔离到管理端口;OpenAPI 176/214 | | 其余五个功能组 | 技能词云、入职清单/公司知识、薪资计算器/参考、诚信积分/激励、简历查重 | N03–N06、N08仍属一期,细节设计后编码 | | 上线保障 | 现有资产待逐项核验 | G01–G04,一期交付门槛 | | 后续候选池 | AI简历、AI模拟面试/预测、SHAP高级解释等 | 以清单第6节为准,移出一期 | diff --git a/docs/frontend-integration.md b/docs/frontend-integration.md index 16f4bd0..7e92da7 100644 --- a/docs/frontend-integration.md +++ b/docs/frontend-integration.md @@ -990,3 +990,27 @@ V43 仅供管理员后台使用,前端不得向学生或企业展示。当前 可用接口、参数和小样本规则见 [candidate-scoring-fairness-api.md](candidate-scoring-fairness-api.md)。前端不得根据城市、学校、姓名等字段创建敏感人群筛选,也不得尝试通过多次小样本查询反推出候选人。 总览、维度和申诉响应都显式返回 `availability/reason`。状态/版本/回滚原因及分布桶均按独立候选人数计算;`appealCount` 仅表示事件数。`SUPPRESSED` 时不得用总体计数、补集或其它接口字段反推出被隐藏比例,`NO_DATA`、`INSUFFICIENT_SAMPLE`、`SUPPRESSED` 均不得渲染为 `NORMAL`。 + +## 13. 企业下载候选人简历(S03) + +``` +GET /api/v1/company/candidates/{applicationId}/resume +Authorization: Bearer +``` + +响应是**文件流**(不是 JSON 包装),前端应按二进制处理: + +- 成功:`200` + `Content-Type`(PDF/DOC/DOCX)+ `Content-Disposition: attachment; filename*=UTF-8''…` + + `Cache-Control: no-store` +- `401` 未登录或会话失效 +- `403` 非 COMPANY 角色、无企业资料、跨企业投递,或候选人隐私设置不允许 +- `404` 投递不存在(`APPLICATION_NOT_FOUND`)或学生没有简历(`PROFILE_NOT_FOUND`) + +前端注意事项: + +1. **不要**从候选人列表/详情里找 `resumeUrl` —— 这些 DTO 有意不含该字段; + 下载必须走上面这个接口。 +2. 403 与 404 都按「不可下载」处理,**不要**在 UI 上区分「隐私拒绝」与「跨企业」, + 避免泄露归属信息。 +3. 下载链接不要放进 URL 或日志;如需预览,请用同一 Authorization 头请求。 +4. 响应已声明 `no-store`,前端不要再把简历文件写入本地持久缓存。 diff --git a/docs/openapi-rc.json b/docs/openapi-rc.json index a4c18f0..24e4e5e 100644 --- a/docs/openapi-rc.json +++ b/docs/openapi-rc.json @@ -9267,6 +9267,40 @@ } } } + }, + "/company/candidates/{applicationId}/resume": { + "get": { + "tags": [ + "投递" + ], + "summary": "企业下载候选人简历", + "description": "按候选人隐私设置授权后返回简历文件流;不返回存储路径、学生 ID 或联系方式", + "operationId": "downloadCandidateResume", + "parameters": [ + { + "name": "applicationId", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "type": "string", + "format": "byte" + } + } + } + } + } + } } }, "components": { diff --git a/docs/phase1-scope-and-acceptance.md b/docs/phase1-scope-and-acceptance.md index fe07a4c..c7ffe09 100644 --- a/docs/phase1-scope-and-acceptance.md +++ b/docs/phase1-scope-and-acceptance.md @@ -31,6 +31,7 @@ | C08 推荐与人才邀约 | 岗位推荐、学生授权发现、企业邀约与接受后沟通 | recommendation/、talentdiscovery/、talentinvitation/ | 默认不发现;同企业历史投递排除;限额/冷却/幂等;接受后会话隐私 | 张顺 / 小刘 | | S01 认证安全整改 | 验证码不落日志、受控取码、`/jobs` 认证边界 | auth/、docs/smoke-test.sh、job/JobController.java | 双开关受控取码;mock=false 完整回滚;`/jobs` 权限矩阵(独立代码复验与真实 MySQL/Docker 黑盒已通过) | 张顺 / 独立复验 | | S02 Token 隔离与会话吊销 | Access/Refresh 类型与密钥隔离、轮换、重放处置、会话吊销 | auth/(JwtTokenProvider、AuthSessionService、AuthTokenHasher、JwtAuthenticationFilter、AuthController)、V42__auth_token_sessions.sql | Refresh 不能访问普通接口;Access 不能刷新;二次使用撤销当前会话;并发刷新最多成功一次;登出/注销完成使会话失效 | 张顺 / 独立复验 | +| S03 生产配置与简历隐私 | 历史种子账号治理、一次性管理员初始化、企业简历下载授权、Actuator 隔离 | auth/(AdminBootstrapRunner)、application/(CompanyResumeAccessPolicy、CompanyResumeService)、V43__disable_legacy_seed_accounts.sql、application.yml | V43 只禁用完全匹配原始指纹的三条种子账号;管理员初始化默认关闭且幂等;三种可见性 × 全部投递状态 × 跨企业/未认证企业矩阵;拒绝路径零文件读取;业务端口不提供指标 | 张顺 / 独立复验 | ## 3. 一期必须补齐的八个功能组 diff --git a/docs/privacy-settings-api.md b/docs/privacy-settings-api.md index c4415d6..0fe931b 100644 --- a/docs/privacy-settings-api.md +++ b/docs/privacy-settings-api.md @@ -99,4 +99,31 @@ ## 6. 审计 -更新隐私设置会写入审计日志 `UPDATE_PRIVACY_SETTINGS`。审计中**不得**写入 `phone / email / password / jwt / resumeUrl` 等敏感值。 \ No newline at end of file +更新隐私设置会写入审计日志 `UPDATE_PRIVACY_SETTINGS`。审计中**不得**写入 `phone / email / password / jwt / resumeUrl` 等敏感值。 +## S03-B:`resumeVisibility` 的企业侧执行边界 + +此前 `resumeVisibility` 只有存储与读写接口,**没有任何企业侧代码路径读取它**,等同于空转开关。 +S03 起该设置在企业读取简历文件的唯一入口 `GET /company/candidates/{applicationId}/resume` +上真正生效,判定全部由失败关闭的 `CompanyResumeAccessPolicy` 完成。 + +### 可见性决策表 + +| 设置 | 企业条件 | 投递条件 | 结果 | +|---|---|---|---| +| 无设置记录 / `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 | + +### 重要变更 + +- **`APPLIED_COMPANIES` 与 `VERIFIED_COMPANIES` 现在都强制校验「投递所属岗位属于当前企业」。** + 此前 `canCompanyViewResume` 不校验公司归属:`APPLIED_COMPANIES` 完全不使用 `companyId`, + `VERIFIED_COMPANIES` 只校验企业 `verified` —— 任何已认证企业都可凭任意 `applicationId` 查看简历。 +- `VERIFIED_COMPANIES` **不是**「知道学生 ID 即可查看」:仍需合法的候选业务上下文。 +- 隐私拒绝统一返回 `FORBIDDEN(2002)`;跨企业同样 403,不通过差异响应泄露归属。 +- `PrivacySettingsService.canCompanyViewResume` 保留为委托方法,判定逻辑已收束到策略类, + 不再保留可被误用的宽松实现。 diff --git a/docs/resume-api.md b/docs/resume-api.md index 17534d0..d9d3a5f 100644 --- a/docs/resume-api.md +++ b/docs/resume-api.md @@ -215,3 +215,42 @@ GET /resume-export-tasks/{taskId}/download | 8204 | `RESUME_EXPORT_IN_PROGRESS` | 409 | 导出任务进行中,不可删除版本 | | 8205 | `DUPLICATE_DEFAULT_RESUME` | 409 | 并发设置默认冲突 | | 8206 | `RESUME_EXPORT_NOT_READY` | 409 | 导出任务未完成,不可下载 | + +## S03-B:企业下载候选人简历 + +### `GET /company/candidates/{applicationId}/resume` + +新增的**唯一**企业侧简历读取入口。返回文件流,**不返回** `resumeUrl`、存储相对/绝对路径、 +学生用户 ID、手机号或邮箱。 + +- 认证:`Authorization: Bearer ` +- 路径参数:`applicationId`(本企业岗位下的投递) +- 响应:二进制文件流;`Content-Type` 只取自上传时已校验的白名单 + (`application/pdf`、`application/msword`、DOCX),否则回退 `application/octet-stream` +- 响应头:`Content-Disposition: attachment; filename*=UTF-8''<已净化文件名>`, + 以及 `Cache-Control: no-store`(私有简历不进任何中间缓存) +- 文件名继续经过 `ResumeFilenameSanitizer`,拒绝路径分隔符、控制字符与响应头换行 + +### 错误语义 + +| 场景 | 结果 | +|---|---| +| 未登录 / 无有效会话 | 401 | +| 非 COMPANY 角色(STUDENT / ADMIN) | 403 | +| 当前用户没有企业资料 | 403 | +| 投递不存在 | 404 `APPLICATION_NOT_FOUND` | +| 跨企业投递 | 403 | +| 隐私设置不允许(含 PRIVATE / 无设置) | 403 `FORBIDDEN` | +| 授权通过但学生没有简历 | 404 `PROFILE_NOT_FOUND` | + +### 授权与副作用 + +- 授权顺序:当前 COMPANY 用户 → 当前 Company → Application+Job → `Job.companyId` → + Student → privacy setting → profile/file。归属信息全部由服务端推导, + 不接受客户端传入的 `companyId`/`studentUserId`。 +- **拒绝路径不会调用存储读取**(测试以计数包装器验证为零次)。 +- 该接口只读:不修改 `student_profiles`、`resume_versions`、投递、评分、排名、推荐、邀约、 + 消息、通知或风险表;不发送 RabbitMQ、短信或邮件。 +- 学生本人的上传、下载、版本与导出接口语义完全不变。 +- 企业候选人列表/详情 DTO **仍不含** `resumeUrl`。 +- 不新增按 `studentUserId` 或 `candidateKey` 下载简历的入口;人才发现本轮不得直接下载简历。 diff --git a/docs/s03-independent-mysql-acceptance-20260915.md b/docs/s03-independent-mysql-acceptance-20260915.md new file mode 100644 index 0000000..c7c46cc --- /dev/null +++ b/docs/s03-independent-mysql-acceptance-20260915.md @@ -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 黑盒全部通过,可以进入精确暂存检查点。** \ No newline at end of file diff --git a/docs/s03-production-security-and-resume-privacy-design.md b/docs/s03-production-security-and-resume-privacy-design.md new file mode 100644 index 0000000..a9cfed1 --- /dev/null +++ b/docs/s03-production-security-and-resume-privacy-design.md @@ -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 黑盒通过后才能冻结。 diff --git a/docs/s03-production-security-and-resume-privacy-implementation-instruction.md b/docs/s03-production-security-and-resume-privacy-implementation-instruction.md new file mode 100644 index 0000000..5cc1430 --- /dev/null +++ b/docs/s03-production-security-and-resume-privacy-implementation-instruction.md @@ -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 -- /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 黑盒验收。” diff --git a/docs/verify_openapi.py b/docs/verify_openapi.py index 8313aba..0baf84d 100644 --- a/docs/verify_openapi.py +++ b/docs/verify_openapi.py @@ -6,7 +6,7 @@ p = PROJECT_ROOT / 'docs' / 'openapi-rc.json' d = json.load(open(p, encoding='utf-8')) print('paths:', len(d['paths'])) -assert len(d['paths']) == 175, f"Expected 175 paths, got {len(d['paths'])}" +assert len(d['paths']) == 176, f"Expected 176 paths, got {len(d['paths'])}" paths = d['paths'] @@ -632,10 +632,38 @@ assert _auth_paths == { }, f'Unexpected /auth paths: {sorted(_auth_paths)}' print(' S02 refresh/logout operations and isolation schemas validated') -# 统计 operation 总数并校验 V43 + N07 + S02 计数 213 +# ── S03:唯一新增的企业简历下载接口 ──────────────────────────────── +_download_path = '/company/candidates/{applicationId}/resume' +assert _download_path in paths, 'Missing company resume download endpoint' +_download_get = paths[_download_path]['get'] +assert 'requestBody' not in _download_get, 'Resume download must not declare requestBody' +assert set(paths[_download_path].keys()) == {'get'}, 'Resume download must be GET only' + +# 不得存在按 studentUserId / candidateKey 下载简历的入口 +for _p in paths: + _lower = _p.lower() + if 'resume' in _lower or 'download' in _lower: + assert 'studentuserid' not in _lower, f'Resume path must not key on studentUserId: {_p}' + assert 'candidatekey' not in _lower, f'Resume path must not key on candidateKey: {_p}' + +# 企业候选人 DTO 不得回带 resumeUrl / studentUserId / phone / email / storagePath +for _dto in ('CandidateDetailView', 'CandidateItemView'): + _node = d['components']['schemas'].get(_dto) + if _node is None: + continue + _text = json.dumps(resolve_schema(_node), ensure_ascii=False) + for _forbidden in ('resumeUrl', 'studentUserId', 'phone', 'email', 'storagePath'): + assert _forbidden not in _text, f'{_dto} exposes forbidden field {_forbidden}' + +# Actuator 不得进入业务 OpenAPI +for _p in paths: + assert not _p.startswith('/actuator'), f'Actuator must not appear in the business contract: {_p}' +print(' S03 company resume download contract validated; candidate DTOs remain redacted') + +# 统计 operation 总数并校验 V43 + N07 + S02 + S03 计数 214 operations = sum(len(methods) for methods in d['paths'].values()) print('operations:', operations) -assert operations == 213, f"Expected 213 operations, got {operations}" +assert operations == 214, f"Expected 214 operations, got {operations}" print(" Offer advance endpoints all present") print(" 26 operations all present") print(" Account deletion endpoints present (3 operations)") diff --git a/mydreamjob-server/pom.xml b/mydreamjob-server/pom.xml index 55db580..6c77e07 100644 --- a/mydreamjob-server/pom.xml +++ b/mydreamjob-server/pom.xml @@ -54,6 +54,11 @@ org.springframework.boot spring-boot-starter-actuator + + + io.micrometer + micrometer-registry-prometheus + diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationController.java b/mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationController.java index f719074..6db3346 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationController.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationController.java @@ -4,6 +4,7 @@ import com.mydreamjob.common.api.ApiResponse; import com.mydreamjob.common.exception.BusinessException; import com.mydreamjob.common.exception.ErrorCode; import com.mydreamjob.application.dto.CandidateDetailView; +import com.mydreamjob.application.dto.CompanyResumeFile; import com.mydreamjob.application.dto.CandidateListResponse; import com.mydreamjob.application.dto.ApplicationCreateRequest; import com.mydreamjob.application.dto.ApplicationStatusRequest; @@ -15,11 +16,15 @@ import jakarta.servlet.http.HttpServletRequest; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; +import org.springframework.http.ContentDisposition; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.web.bind.annotation.*; +import java.nio.charset.StandardCharsets; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -34,6 +39,7 @@ public class ApplicationController { private final ApplicationService applicationService; private final CompanyService companyService; private final UserRepository userRepository; + private final CompanyResumeService companyResumeService; private static final Set ALLOWED_STATUSES = Set.of( "SUBMITTED", "VIEWED", "INTERVIEW", "HIRED", "REJECTED", "WITHDRAWN"); @@ -204,6 +210,29 @@ public class ApplicationController { return ResponseEntity.ok(ApiResponse.ok(data, getRequestId(request))); } + @Operation(summary = "企业下载候选人简历", + description = "按候选人隐私设置授权后返回简历文件流;不返回存储路径、学生 ID 或联系方式") + @GetMapping("/company/candidates/{applicationId}/resume") + public ResponseEntity downloadCandidateResume( + @PathVariable Long applicationId, + HttpServletRequest request) { + Long userId = resolveUserId(request); + assertCompanyRole(userId); + + // 授权全部在 Service/Policy 内完成:这里不传递任何归属信息,只传 applicationId。 + CompanyResumeFile file = companyResumeService.load(userId, applicationId); + + ContentDisposition disposition = ContentDisposition.attachment() + .filename(file.filename(), StandardCharsets.UTF_8) + .build(); + return ResponseEntity.ok() + .contentType(MediaType.parseMediaType(file.contentType())) + .header(HttpHeaders.CONTENT_DISPOSITION, disposition.toString()) + // 私有简历不进任何中间缓存。 + .header(HttpHeaders.CACHE_CONTROL, "no-store") + .body(file.content()); + } + // ── 辅助 ────────────────────────────────────────────────── private void assertCompanyRole(Long userId) { diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/application/CompanyResumeAccessPolicy.java b/mydreamjob-server/src/main/java/com/mydreamjob/application/CompanyResumeAccessPolicy.java new file mode 100644 index 0000000..0f858ac --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/application/CompanyResumeAccessPolicy.java @@ -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 企业简历访问授权(失败关闭)。 + * + *

授权顺序严格按设计 §4.2:当前 COMPANY 用户 → 当前 Company → Application+Job → + * Job.companyId → Student → privacy setting。归属信息全部由服务端推导, + * 不信任任何客户端传入的 companyId / studentUserId。

+ * + *

决策表(设计 §4.3):

+ *
    + *
  • 无设置记录 / null / PRIVATE:拒绝(按 PRIVATE 处理)
  • + *
  • APPLIED_COMPANIES:岗位必须属于当前企业,且投递状态为 + * SUBMITTED/VIEWED/INTERVIEW/HIRED;WITHDRAWN/REJECTED 拒绝
  • + *
  • VERIFIED_COMPANIES:当前企业 {@code verified=true} 且拥有该投递岗位; + * 投递状态不额外限制
  • + *
+ * + *

本类修复了此前 {@code PrivacySettingsService.canCompanyViewResume} 只校验学生与状态、 + * 不校验企业归属的缺陷。

+ */ +@Component +@RequiredArgsConstructor +public class CompanyResumeAccessPolicy { + + /** + * {@code APPLIED_COMPANIES} 允许的投递状态:**严格白名单**。 + * + *

不得改为「不是拒绝状态就允许」的黑名单逻辑 —— 那会让 {@code null}、空白、 + * 未知状态或大小写变体(如 {@code "submitted"})失败开放。状态比较区分大小写。

+ */ + static final Set 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} 委托)。 + * + *

调用方提供的 {@code studentUserId} 只用于一致性校验:必须与投递记录一致, + * 否则视为不可信并拒绝。

+ */ + 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(); + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/application/CompanyResumeService.java b/mydreamjob-server/src/main/java/com/mydreamjob/application/CompanyResumeService.java new file mode 100644 index 0000000..b68a36d --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/application/CompanyResumeService.java @@ -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 企业读取候选人简历文件。 + * + *

本类只负责「授权已通过之后」的文件读取与响应构建: + * 授权全部由 {@link CompanyResumeAccessPolicy} 完成,**任何拒绝路径都不会走到 + * {@link ResumeStorageService#read}。

+ * + *

简历正文、存储路径、原始文件名与学生标识都不写入日志、审计或异常。

+ */ +@Slf4j +@Service +@RequiredArgsConstructor +public class CompanyResumeService { + + /** Content-Type 白名单:只能来自上传时已校验的三种简历类型。 */ + static final Set 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; + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/application/dto/CompanyResumeFile.java b/mydreamjob-server/src/main/java/com/mydreamjob/application/dto/CompanyResumeFile.java new file mode 100644 index 0000000..3d984bf --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/application/dto/CompanyResumeFile.java @@ -0,0 +1,10 @@ +package com.mydreamjob.application.dto; + +/** + * S03-B 企业简历下载的内部响应载体。 + * + *

只携带渲染文件响应所必需的三个字段:文件内容、已净化的下载文件名、白名单内的 + * Content-Type。**不包含**存储路径、学生用户 ID、手机号、邮箱或简历正文文本。

+ */ +public record CompanyResumeFile(byte[] content, String filename, String contentType) { +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/auth/AdminBootstrapCreator.java b/mydreamjob-server/src/main/java/com/mydreamjob/auth/AdminBootstrapCreator.java new file mode 100644 index 0000000..e11e149 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/auth/AdminBootstrapCreator.java @@ -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 定向整改:管理员账号创建的**独立事务组件**。 + * + *

为什么不用 JPA/Hibernate:并发初始化时数据库唯一约束会成为正常并发控制手段, + * 而 Hibernate 的 {@code SqlExceptionHelper} 会在业务代码捕获异常**之前**以 ERROR 级别 + * 记录整条语句与绑定值 —— 管理员手机号因此进入生产可见日志。

+ * + *

本组件改用 {@link JdbcTemplate} 的参数化语句:

+ *
    + *
  • 冲突在 JDBC 层被捕获,**不经过** Hibernate 日志体系;
  • + *
  • 原始数据库异常与 message/cause **一律丢弃**,对外只返回结果码;
  • + *
  • {@code users} 与 {@code user_roles} 在**同一事务**内提交 —— + * 角色写入失败时用户创建整体回滚;
  • + *
  • 不打印 SQL、手机号或绑定参数。
  • + *
+ */ +@Component +public class AdminBootstrapCreator { + + /** 创建结果:成功创建 / 数据库唯一约束冲突。 */ + public enum CreateResult { + CREATED, + CONFLICT + } + + /** 引导账号的最小状态视图(不含任何敏感字段输出)。 */ + public record BootstrapUser(Long id, String role, boolean active, boolean verified, + boolean passwordAbsent, List 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 findByPhone(String phone) { + List 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 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 仓储)。 */ +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/auth/AdminBootstrapRunner.java b/mydreamjob-server/src/main/java/com/mydreamjob/auth/AdminBootstrapRunner.java new file mode 100644 index 0000000..58fb874 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/auth/AdminBootstrapRunner.java @@ -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 一次性管理员初始化(显式开关,默认关闭)。 + * + *

仅在 {@code app.bootstrap.admin.enabled} 为字面量 {@code "true"} 时装配; + * 本类还会再校验一次字面量,确保大小写变体不会误启用。

+ * + *

规则(设计 §3.2):

+ *
    + *
  1. 手机号必须通过既有手机号格式校验;不写日志、异常详情或审计 details;
  2. + *
  3. 已有活动 ADMIN → 无副作用跳过;
  4. + *
  5. 无活动 ADMIN 且手机号未被占用 → 创建 active=true / verified=true 的 ADMIN, + * 同时写入 {@code users.role} 与 {@code user_roles};
  6. + *
  7. 手机号已属于任何用户(任意角色 / inactive / 角色不一致)→ 启动失败, + * 禁止提权、复活或覆盖;
  8. + *
  9. 相同配置重复启动幂等;
  10. + *
  11. 多实例并发:数据库唯一手机号约束是最后防线,冲突直接失败,不吞掉。
  12. + *
+ * + *

不签发 Token、不发送短信、不设置默认密码、不提供公网接口。

+ */ +@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 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 的幂等判据:现存账号必须**恰好**是本次初始化所要求的合法管理员。 + * + *

要求角色为 ADMIN、active、verified、无默认口令、{@code user_roles} 含 ADMIN, + * 且不含 STUDENT/COMPANY 等附加角色。任一不满足即视为「手机号被占用」,启动失败。

+ */ + 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()); + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/common/exception/GlobalExceptionHandler.java b/mydreamjob-server/src/main/java/com/mydreamjob/common/exception/GlobalExceptionHandler.java index fc1eafc..9fedc49 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/common/exception/GlobalExceptionHandler.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/common/exception/GlobalExceptionHandler.java @@ -16,6 +16,7 @@ import org.springframework.web.bind.MissingServletRequestParameterException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; +import org.springframework.web.servlet.resource.NoResourceFoundException; import java.util.HashMap; import java.util.Map; @@ -296,6 +297,17 @@ public class GlobalExceptionHandler { .body(ApiResponse.forbidden("access denied", getRequestId(request))); } + @ExceptionHandler(NoResourceFoundException.class) + public ResponseEntity> handleNoResourceFound( + NoResourceFoundException ex, HttpServletRequest request) { + // S03-C 整改:业务端口 /actuator/** 等不存在资源统一返回 404。 + // 只记录固定文案与归一化 requestId,不回显异常 message、资源路径或堆栈。 + log.warn("No resource found: requestId={}", getRequestId(request)); + return ResponseEntity + .status(HttpStatus.NOT_FOUND) + .body(ApiResponse.error(404, "not found", getRequestId(request))); + } + @ExceptionHandler(Exception.class) public ResponseEntity> handleGenericException( Exception ex, HttpServletRequest request) { diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/config/AdminBootstrapProperties.java b/mydreamjob-server/src/main/java/com/mydreamjob/config/AdminBootstrapProperties.java new file mode 100644 index 0000000..38c3d7c --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/config/AdminBootstrapProperties.java @@ -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 一次性管理员初始化配置。 + * + *

默认关闭;只有显式把开关设为字面量 {@code "true"} 才会装配初始化器。 + * 手机号只能来自环境变量,**没有默认值**,因此不会误建管理员。

+ */ +@Getter +@Setter +@Component +@ConfigurationProperties(prefix = "app.bootstrap.admin") +public class AdminBootstrapProperties { + + /** 是否启用一次性管理员初始化(严格 true 才生效)。 */ + private String enabled = "false"; + + /** 管理员手机号;无默认值,必须由部署方显式提供。 */ + private String phone = ""; + + /** 管理员昵称。 */ + private String nickname = "平台管理员"; +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/config/SecurityConfig.java b/mydreamjob-server/src/main/java/com/mydreamjob/config/SecurityConfig.java index 038c657..14614b3 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/config/SecurityConfig.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/config/SecurityConfig.java @@ -60,9 +60,14 @@ public class SecurityConfig { "{\"code\":403,\"message\":\"access denied\",\"timestamp\":\"\"}"); })) .authorizeHttpRequests(auth -> auth - // Actuator 健康检查 - .requestMatchers("/actuator/health/**").permitAll() - .requestMatchers("/actuator/info").permitAll() + // S03-C 整改:业务端口统一放行 /actuator/**。 + // Actuator 端点仍只注册在独立管理端口(默认 127.0.0.1),业务端口上 + // /actuator/** 全部落入 MVC 的不存在资源处理,由 GlobalExceptionHandler 的 + // NoResourceFoundException 精确映射为 404 —— 避免匿名请求先被认证层 + // 拦截成 401(未放行路径)或落入通用异常映射成 500(放行路径)。 + // permitAll 不代表暴露指标,只保证所有 /actuator/** 都走到同一 404 语义; + // 业务端口不得新增任何 Actuator Controller 或端点映射。 + .requestMatchers("/actuator/**").permitAll() // Swagger UI .requestMatchers("/swagger-ui/**", "/v3/api-docs/**", "/api-docs/**").permitAll() // 健康检查 API diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/privacy/PrivacySettingsService.java b/mydreamjob-server/src/main/java/com/mydreamjob/privacy/PrivacySettingsService.java index d99c992..fa0a534 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/privacy/PrivacySettingsService.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/privacy/PrivacySettingsService.java @@ -1,14 +1,11 @@ package com.mydreamjob.privacy; -import com.mydreamjob.application.Application; -import com.mydreamjob.application.ApplicationRepository; +import com.mydreamjob.application.CompanyResumeAccessPolicy; import com.mydreamjob.audit.AuditService; import com.mydreamjob.auth.User; import com.mydreamjob.auth.UserRepository; 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.privacy.dto.PrivacySettingsResponse; import com.mydreamjob.privacy.dto.PrivacySettingsUpdateRequest; import lombok.RequiredArgsConstructor; @@ -18,8 +15,6 @@ import org.springframework.orm.ObjectOptimisticLockingFailureException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import java.util.Set; - /** * 学生隐私设置服务。 * @@ -31,14 +26,15 @@ import java.util.Set; @RequiredArgsConstructor public class PrivacySettingsService { - /** 视为"非有效投递"的状态:撤销或已拒绝的投递不允许企业查看简历 */ - private static final Set INEFFECTIVE_STATUSES = Set.of("WITHDRAWN", "REJECTED"); - private final StudentPrivacySettingsRepository privacyRepository; private final UserRepository userRepository; - private final ApplicationRepository applicationRepository; - private final CompanyRepository companyRepository; private final AuditService auditService; + /** + * S03-B:企业简历可见性统一由失败关闭的授权策略判定。 + * 策略强制校验「投递所属岗位属于该企业」,修复了此前只校验学生与状态、 + * 不校验企业归属的缺陷。 + */ + private final CompanyResumeAccessPolicy companyResumeAccessPolicy; /** 校验当前用户必须为 STUDENT,否则抛 FORBIDDEN。 */ public void assertStudentRole(Long userId) { @@ -105,33 +101,20 @@ public class PrivacySettingsService { } /** - * 企业是否可查看某学生的简历。 + * 企业是否可查看某学生的简历(S03-B:委托给失败关闭的授权策略)。 + * + *

判定需要同时满足:投递存在、投递学生与传入 {@code studentUserId} 一致、 + * 投递所属岗位属于 {@code companyId},再按隐私设置决定:

* *
    - *
  • PRIVATE:企业不可见
  • - *
  • APPLIED_COMPANIES:仅当该学生在该企业存在有效投递时可见(WITHDRAWN/REJECTED 不算)
  • - *
  • VERIFIED_COMPANIES:仅当企业 verified=true 时可见
  • + *
  • 无记录 / null / PRIVATE:不可见
  • + *
  • APPLIED_COMPANIES:岗位属于该企业且投递状态为 + * SUBMITTED/VIEWED/INTERVIEW/HIRED
  • + *
  • VERIFIED_COMPANIES:该企业 {@code verified=true} 且拥有该投递岗位
  • *
*/ public boolean canCompanyViewResume(Long studentUserId, Long companyId, Long applicationId) { - StudentPrivacySettings settings = privacyRepository.findByStudentUserId(studentUserId).orElse(null); - ResumeVisibility visibility = (settings == null || settings.getResumeVisibility() == null) - ? ResumeVisibility.PRIVATE : settings.getResumeVisibility(); - switch (visibility) { - case VERIFIED_COMPANIES: - Company company = companyRepository.findById(companyId).orElse(null); - return company != null && Boolean.TRUE.equals(company.getVerified()); - case APPLIED_COMPANIES: - // 投递表不含 companyId,以 applicationId 定位具体投递校验有效状态(WITHDRAWN/REJECTED 不算有效) - Application app = applicationRepository.findById(applicationId).orElse(null); - if (app == null || !studentUserId.equals(app.getStudentUserId())) { - return false; - } - return !INEFFECTIVE_STATUSES.contains(app.getStatus()); - case PRIVATE: - default: - return false; - } + return companyResumeAccessPolicy.isVisible(studentUserId, companyId, applicationId); } private void validate(PrivacySettingsUpdateRequest req) { diff --git a/mydreamjob-server/src/main/resources/application.yml b/mydreamjob-server/src/main/resources/application.yml index 2cc9e32..cb9192c 100644 --- a/mydreamjob-server/src/main/resources/application.yml +++ b/mydreamjob-server/src/main/resources/application.yml @@ -91,6 +91,14 @@ app: issuer: mydreamjob access-audience: mydreamjob-api refresh-audience: mydreamjob-auth + + # S03-A:一次性管理员初始化。默认关闭;手机号无默认值,必须由部署方显式提供。 + # 初始化成功后部署方必须移除该开关,普通启动不得隐式创建管理员。 + bootstrap: + admin: + enabled: ${ADMIN_BOOTSTRAP_ENABLED:false} + phone: ${ADMIN_BOOTSTRAP_PHONE:} + nickname: ${ADMIN_BOOTSTRAP_NICKNAME:平台管理员} resume: storage-type: ${RESUME_STORAGE_TYPE:local} storage-dir: ${RESUME_STORAGE_DIR:./resumes} @@ -154,14 +162,27 @@ recommendation: enabled: ${RECOMMENDATION_ASSESSMENT_ENABLED:false} management: + # S03-C:Actuator 隔离到独立管理端口,默认只绑定回环地址。 + # 业务端口不再提供 /actuator/metrics 与 /actuator/prometheus; + # 需要采集指标时由部署层通过受控网络或反向代理访问管理端口。 + server: + port: ${MANAGEMENT_SERVER_PORT:8081} + address: ${MANAGEMENT_SERVER_ADDRESS:127.0.0.1} endpoints: web: exposure: include: health,info,metrics,prometheus + # S03-C:显式开启 Prometheus 指标导出。仅暴露端点 ID 而不开启导出时, + # /actuator/prometheus 不会注册(暴露清单会指向一个不存在的端点)。 + prometheus: + metrics: + export: + enabled: true endpoint: health: - show-details: when-authorized - show-components: always + # 健康探针只返回最小 UP/DOWN,不暴露数据库、Redis、RabbitMQ、磁盘或连接池细节。 + show-details: never + show-components: never health: db: enabled: true diff --git a/mydreamjob-server/src/main/resources/db/migration/V43__disable_legacy_seed_accounts.sql b/mydreamjob-server/src/main/resources/db/migration/V43__disable_legacy_seed_accounts.sql new file mode 100644 index 0000000..bdb72b1 --- /dev/null +++ b/mydreamjob-server/src/main/resources/db/migration/V43__disable_legacy_seed_accounts.sql @@ -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; diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/application/CompanyResumeAccessPolicyTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/application/CompanyResumeAccessPolicyTest.java new file mode 100644 index 0000000..6fdf1dc --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/application/CompanyResumeAccessPolicyTest.java @@ -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)。 + * + *

真实策略 + Mock Repository;覆盖三种可见性 × 全部投递状态 × 公司归属 × 跨企业 × 未认证, + * 以及下载入口的拒绝语义与错误码。

+ */ +@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 ALL_STATUSES = List.of( + "SUBMITTED", "VIEWED", "INTERVIEW", "HIRED", "REJECTED", "WITHDRAWN"); + private static final List EFFECTIVE = List.of("SUBMITTED", "VIEWED", "INTERVIEW", "HIRED"); + private static final List 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()); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/application/CompanyResumeDownloadIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/application/CompanyResumeDownloadIntegrationTest.java new file mode 100644 index 0000000..d6e2518 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/application/CompanyResumeDownloadIntegrationTest.java @@ -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 + 真实本地存储。 + * + *

只用一个计数包装器包住存储实现,用于证明**拒绝路径零文件读取**; + * 授权链本身完全真实,未被 Mock。

+ */ +@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 ALL_STATUSES = + List.of("SUBMITTED", "VIEWED", "INTERVIEW", "HIRED", "REJECTED", "WITHDRAWN"); + private static final List 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 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 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(); + 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 digestColumns() { + java.util.Map 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; + } + + /** + * 行级快照:按选定字段排序后取全部行,做深比较。 + * + *

不使用 COUNT / SUM(id) / CRC32(id) 这类弱摘要;查询异常直接抛出使测试失败, + * 不允许被吞掉后写入占位值。

+ */ + private java.util.Map>> snapshot() { + java.util.Map>> out = new java.util.LinkedHashMap<>(); + for (java.util.Map.Entry 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; + } + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/auth/AdminBootstrapConcurrencyLoggingTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/auth/AdminBootstrapConcurrencyLoggingTest.java new file mode 100644 index 0000000..231248f --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/auth/AdminBootstrapConcurrencyLoggingTest.java @@ -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 定向整改:管理员初始化并发冲突**不得**把完整手机号写进任何日志。 + * + *

并发竞争由数据库唯一约束兜底时,Hibernate 的 {@code SqlExceptionHelper} 会在业务代码 + * 捕获异常**之前**记录整条 SQL 与绑定参数 —— 手机号因此进入日志。本类捕获 + * root / {@code com.mydreamjob} / {@code org.hibernate} / {@code org.springframework.jdbc} + * 四类 logger,并把 {@code throwableProxy} 的完整文本(含 cause 链与堆栈)纳入扫描。

+ */ +@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 redisTemplate; + + @Autowired + private AdminBootstrapRunner runner; + + @Autowired + private JdbcTemplate jdbcTemplate; + + private final List> appenders = new ArrayList<>(); + private final List attachedLoggers = new ArrayList<>(); + private final List 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 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 全文。 + * + *

排除 {@code org.springframework.test.*}:测试框架自身会在 DEBUG 记录 + * 上下文缓存键(其中包含本次 {@code @SpringBootTest} 的属性列表,因而含手机号)。 + * 那是测试基础设施的输出,不是产品日志 —— 生产运行不存在该 logger。

+ */ + private String capturedText() { + StringBuilder sb = new StringBuilder(); + for (ListAppender 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 runConcurrently() throws Exception { + ExecutorService pool = Executors.newFixedThreadPool(2); + CountDownLatch start = new CountDownLatch(1); + CountDownLatch ready = new CountDownLatch(2); + List> 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 results = new ArrayList<>(); + for (Future 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 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> 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; + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/auth/AdminBootstrapDisabledByDefaultTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/auth/AdminBootstrapDisabledByDefaultTest.java new file mode 100644 index 0000000..b1a0356 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/auth/AdminBootstrapDisabledByDefaultTest.java @@ -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 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(); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/auth/AdminBootstrapIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/auth/AdminBootstrapIntegrationTest.java new file mode 100644 index 0000000..d4934ef --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/auth/AdminBootstrapIntegrationTest.java @@ -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 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 appender; + private ch.qos.logback.classic.Logger appLogger; + /** 用例开始前被临时停用、结束后需恢复的其他 ADMIN id。 */ + private final java.util.List 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> 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 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> 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 results = new java.util.ArrayList<>(); + for (Future 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; + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/auth/AdminBootstrapRunnerTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/auth/AdminBootstrapRunnerTest.java new file mode 100644 index 0000000..98f6650 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/auth/AdminBootstrapRunnerTest.java @@ -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)。 + * + *

真实 {@link AdminBootstrapRunner} + Mock 仓储,逐条覆盖任务书 §四 要求的场景。 + * 日志脱敏用真实 Logback 捕获验证。

+ */ +@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 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), + "输出不得包含手机号"); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/auth/AdminBootstrapTransactionTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/auth/AdminBootstrapTransactionTest.java new file mode 100644 index 0000000..5361a1d --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/auth/AdminBootstrapTransactionTest.java @@ -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 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); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/auth/LegacySeedAccountDisableMigrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/auth/LegacySeedAccountDisableMigrationTest.java new file mode 100644 index 0000000..93029ec --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/auth/LegacySeedAccountDisableMigrationTest.java @@ -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} 的行为验证。 + * + *

直接读取并执行迁移文件中的语句,验证:完整指纹命中、任一单字段偏差不命中、 + * 非固定 ID 也能命中、重复执行幂等、关联数据保留、迁移文件本身的约束。

+ * + *

V1_1 的种子账号仍占用原始手机号(唯一约束),因此用例开始前把它们移到哨兵指纹, + * 结束时原样恢复;用例自身插入的行按昵称前缀清理。

+ */ +@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 SEED_PHONES = List.of("13800000001", "13800000002", "13800000003"); + + @MockBean + private RedisTemplate redisTemplate; + + @Autowired + private JdbcTemplate jdbcTemplate; + + private String migrationSql; + private final List> 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 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 语句。 + * + *

必须先逐行剥离 {@code --} 注释,否则首个语句块会以注释开头而被整体跳过 + * (那样"不命中"类断言会假通过)。

+ */ + 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> 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); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/auth/support/TestAuthTokens.java b/mydreamjob-server/src/test/java/com/mydreamjob/auth/support/TestAuthTokens.java index d397a90..737791c 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/auth/support/TestAuthTokens.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/auth/support/TestAuthTokens.java @@ -100,6 +100,16 @@ public final class TestAuthTokens { /** 返回 Access Token 原文。 */ public static synchronized String accessToken(Long userId, String role) { + return mintPair(userId, role).access(); + } + + /** 返回可通过 S02 过滤器的 Refresh Token 原文(type=refresh、refresh 密钥、真实会话)。 */ + public static synchronized String refreshToken(Long userId, String role) { + return mintPair(userId, role).refresh(); + } + + /** 同时签发 Access/Refresh 并登记会话;返回对。 */ + private static IssuedPair mintPair(Long userId, String role) { if (LIBRARIES.isEmpty()) { throw new IllegalStateException( "TestAuthTokens is not initialized: no test datasource was registered"); @@ -133,7 +143,10 @@ public final class TestAuthTokens { throw new IllegalStateException( "TestAuthTokens could not write a session into any registered test datasource"); } - return access.token(); + return new IssuedPair(access.token(), refresh.token()); + } + + private record IssuedPair(String access, String refresh) { } private static void writeSession(JdbcTemplate jdbc, Long userId, String role, String refreshHash, diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/config/ActuatorIsolationIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/config/ActuatorIsolationIntegrationTest.java new file mode 100644 index 0000000..1792938 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/config/ActuatorIsolationIntegrationTest.java @@ -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 隔离:业务端口不提供指标,管理端口只绑定配置地址,健康探针最小化。 + * + *

真实启动两个端口(业务随机端口 + 管理随机端口),用真实 HTTP 验证。

+ */ +@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 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 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 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(); + 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); + } + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/privacy/PrivacySettingsServiceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/privacy/PrivacySettingsServiceTest.java index cab1c3c..2932945 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/privacy/PrivacySettingsServiceTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/privacy/PrivacySettingsServiceTest.java @@ -1,14 +1,11 @@ package com.mydreamjob.privacy; -import com.mydreamjob.application.Application; -import com.mydreamjob.application.ApplicationRepository; +import com.mydreamjob.application.CompanyResumeAccessPolicy; import com.mydreamjob.audit.AuditService; import com.mydreamjob.auth.User; import com.mydreamjob.auth.UserRepository; 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.privacy.dto.PrivacySettingsResponse; import com.mydreamjob.privacy.dto.PrivacySettingsUpdateRequest; import org.junit.jupiter.api.BeforeEach; @@ -32,8 +29,7 @@ class PrivacySettingsServiceTest { @Mock private StudentPrivacySettingsRepository privacyRepository; @Mock private UserRepository userRepository; - @Mock private ApplicationRepository applicationRepository; - @Mock private CompanyRepository companyRepository; + @Mock private CompanyResumeAccessPolicy companyResumeAccessPolicy; @Mock private AuditService auditService; @InjectMocks private PrivacySettingsService service; @@ -205,84 +201,14 @@ class PrivacySettingsServiceTest { // ── 隐私策略 ───────────────────────────────────────────── @Test - void canCompanyViewResume_private_alwaysFalseEvenWithVerifiedCompany() { - when(privacyRepository.findByStudentUserId(STUDENT_ID)) - .thenReturn(Optional.of(settings(ResumeVisibility.PRIVATE, false))); - assertFalse(service.canCompanyViewResume(STUDENT_ID, COMPANY_ID, APP_ID)); - } - - @Test - void canCompanyViewResume_defaultPrivate_whenNoRecord() { - when(privacyRepository.findByStudentUserId(STUDENT_ID)).thenReturn(Optional.empty()); - assertFalse(service.canCompanyViewResume(STUDENT_ID, COMPANY_ID, APP_ID)); - } - - @Test - void canCompanyViewResume_appliedCompanies_validApplication_visible() { - when(privacyRepository.findByStudentUserId(STUDENT_ID)) - .thenReturn(Optional.of(settings(ResumeVisibility.APPLIED_COMPANIES, false))); - Application app = Application.builder().id(APP_ID).studentUserId(STUDENT_ID).status("SUBMITTED").build(); - when(applicationRepository.findById(APP_ID)).thenReturn(Optional.of(app)); - + void canCompanyViewResume_delegatesToAccessPolicy() { + // S03-B:判定逻辑已收束到失败关闭的 CompanyResumeAccessPolicy,本服务只负责委托。 + // 完整决策矩阵(含公司归属)由 CompanyResumeAccessPolicyTest 覆盖; + // 原先在此断言"已验证企业无需拥有岗位即可查看简历"的用例编码的正是被修复的缺陷,故一并移除。 + when(companyResumeAccessPolicy.isVisible(STUDENT_ID, COMPANY_ID, APP_ID)).thenReturn(true); assertTrue(service.canCompanyViewResume(STUDENT_ID, COMPANY_ID, APP_ID)); - } - - @Test - void canCompanyViewResume_appliedCompanies_withdrawn_notVisible() { - when(privacyRepository.findByStudentUserId(STUDENT_ID)) - .thenReturn(Optional.of(settings(ResumeVisibility.APPLIED_COMPANIES, false))); - Application app = Application.builder().id(APP_ID).studentUserId(STUDENT_ID).status("WITHDRAWN").build(); - when(applicationRepository.findById(APP_ID)).thenReturn(Optional.of(app)); - - assertFalse(service.canCompanyViewResume(STUDENT_ID, COMPANY_ID, APP_ID)); - } - - @Test - void canCompanyViewResume_appliedCompanies_rejected_notVisible() { - when(privacyRepository.findByStudentUserId(STUDENT_ID)) - .thenReturn(Optional.of(settings(ResumeVisibility.APPLIED_COMPANIES, false))); - Application app = Application.builder().id(APP_ID).studentUserId(STUDENT_ID).status("REJECTED").build(); - when(applicationRepository.findById(APP_ID)).thenReturn(Optional.of(app)); - - assertFalse(service.canCompanyViewResume(STUDENT_ID, COMPANY_ID, APP_ID)); - } - - @Test - void canCompanyViewResume_appliedCompanies_wrongStudent_notVisible() { - when(privacyRepository.findByStudentUserId(STUDENT_ID)) - .thenReturn(Optional.of(settings(ResumeVisibility.APPLIED_COMPANIES, false))); - Application app = Application.builder().id(APP_ID).studentUserId(999L).status("SUBMITTED").build(); - when(applicationRepository.findById(APP_ID)).thenReturn(Optional.of(app)); - - assertFalse(service.canCompanyViewResume(STUDENT_ID, COMPANY_ID, APP_ID)); - } - - @Test - void canCompanyViewResume_verifiedCompanies_verifiedCompany_visible() { - when(privacyRepository.findByStudentUserId(STUDENT_ID)) - .thenReturn(Optional.of(settings(ResumeVisibility.VERIFIED_COMPANIES, false))); - when(companyRepository.findById(COMPANY_ID)) - .thenReturn(Optional.of(Company.builder().id(COMPANY_ID).verified(true).build())); - - assertTrue(service.canCompanyViewResume(STUDENT_ID, COMPANY_ID, APP_ID)); - } - - @Test - void canCompanyViewResume_verifiedCompanies_unverifiedCompany_notVisible() { - when(privacyRepository.findByStudentUserId(STUDENT_ID)) - .thenReturn(Optional.of(settings(ResumeVisibility.VERIFIED_COMPANIES, false))); - when(companyRepository.findById(COMPANY_ID)) - .thenReturn(Optional.of(Company.builder().id(COMPANY_ID).verified(false).build())); - - assertFalse(service.canCompanyViewResume(STUDENT_ID, COMPANY_ID, APP_ID)); - } - - @Test - void canCompanyViewResume_verifiedCompanies_unknownCompany_notVisible() { - when(privacyRepository.findByStudentUserId(STUDENT_ID)) - .thenReturn(Optional.of(settings(ResumeVisibility.VERIFIED_COMPANIES, false))); - when(companyRepository.findById(COMPANY_ID)).thenReturn(Optional.empty()); + when(companyResumeAccessPolicy.isVisible(STUDENT_ID, COMPANY_ID, APP_ID)).thenReturn(false); assertFalse(service.canCompanyViewResume(STUDENT_ID, COMPANY_ID, APP_ID)); } } \ No newline at end of file