Browse Source

fix: harden SMS authentication and job access control

master
Zhang Shun 1 month ago
parent
commit
b5084fdd9c
  1. 6
      docs/acceptance-week1.md
  2. 4
      docs/api-v0.1.md
  3. 23
      docs/frontend-integration.md
  4. 2
      docs/offer-api.md
  5. 4
      docs/openapi-rc.json
  6. 2
      docs/resume-api.md
  7. 483
      docs/s01-independent-mysql-acceptance-20260911.md
  8. 288
      docs/s01-independent-mysql-acceptance-20260914.md
  9. 786
      docs/s01-remediation-f1f2f3-20260911.md
  10. 225
      docs/smoke-test.sh
  11. 144
      docs/tests/smoke-test-schema-contract-test.sh
  12. 548
      docs/tests/smoke-test-sms-code-test.sh
  13. 3
      mydreamjob-server/src/main/java/com/mydreamjob/auth/AuthService.java
  14. 18
      mydreamjob-server/src/main/java/com/mydreamjob/auth/JwtAuthenticationFilter.java
  15. 35
      mydreamjob-server/src/main/java/com/mydreamjob/auth/SmsVerificationService.java
  16. 23
      mydreamjob-server/src/main/java/com/mydreamjob/auth/sms/MockSmsCodeSender.java
  17. 24
      mydreamjob-server/src/main/java/com/mydreamjob/auth/sms/SmsCodeSender.java
  18. 37
      mydreamjob-server/src/main/java/com/mydreamjob/auth/sms/SmsSenderConfiguration.java
  19. 25
      mydreamjob-server/src/main/java/com/mydreamjob/auth/sms/UnavailableSmsCodeSender.java
  20. 3
      mydreamjob-server/src/main/java/com/mydreamjob/config/SecurityConfig.java
  21. 2
      mydreamjob-server/src/main/java/com/mydreamjob/controller/AuthController.java
  22. 27
      mydreamjob-server/src/main/java/com/mydreamjob/job/JobController.java
  23. 82
      mydreamjob-server/src/main/java/com/mydreamjob/security/CurrentUserAccessor.java
  24. 431
      mydreamjob-server/src/test/java/com/mydreamjob/auth/AuthenticationBoundaryIntegrationTest.java
  25. 79
      mydreamjob-server/src/test/java/com/mydreamjob/auth/SmsEmptyPropertyStartupIntegrationTest.java
  26. 230
      mydreamjob-server/src/test/java/com/mydreamjob/auth/SmsSenderConfigurationTest.java
  27. 115
      mydreamjob-server/src/test/java/com/mydreamjob/auth/SmsUnavailableSenderRollbackIntegrationTest.java
  28. 332
      mydreamjob-server/src/test/java/com/mydreamjob/auth/SmsVerificationSecurityTest.java
  29. 4
      mydreamjob-server/src/test/java/com/mydreamjob/auth/SmsVerificationServiceTest.java
  30. 152
      mydreamjob-server/src/test/java/com/mydreamjob/security/CurrentUserAccessorTest.java

6
docs/acceptance-week1.md

@ -91,7 +91,7 @@ curl http://localhost:8080/api/v1/info
# 发送验证码 # 发送验证码
curl -X POST "http://localhost:8080/api/v1/auth/sms/send?phone=13800000001&purpose=login" curl -X POST "http://localhost:8080/api/v1/auth/sms/send?phone=13800000001&purpose=login"
# Mock 登录(从日志获取验证码) # Mock 登录(验证码不输出到日志;本地按 docs/frontend-integration.md §3 受控取得)
curl -X POST http://localhost:8080/api/v1/auth/login \ curl -X POST http://localhost:8080/api/v1/auth/login \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d '{"phone":"13800000001","code":"<验证码>"}' -d '{"phone":"13800000001","code":"<验证码>"}'
@ -145,11 +145,11 @@ mvn spring-boot:run -Dspring-boot.run.profiles=dev \
| STUDENT | 13800000002 | 学生测试 | | STUDENT | 13800000002 | 学生测试 |
| COMPANY | 13800000003 | 企业测试 | | COMPANY | 13800000003 | 企业测试 |
> 注: 使用 Mock 短信验证码登录,验证码从应用日志中获取 > 注: 使用 Mock 短信验证码登录。应用日志**不再输出验证码**,本地按 `docs/frontend-integration.md` §3 的受控方式取得(需 `SMS_MOCK_ENABLED=true` 且 `ALLOW_TEST_OTP_DB_READ=true`);生产环境禁止查询验证码表。
## 已知限制 ## 已知限制
1. **短信验证码 Mock**: 验证码打印到日志,不发送真实短信 1. **短信验证码 Mock**: 应用日志**不输出验证码**,不发送真实短信;本地取码见 `docs/frontend-integration.md` §3(验证码当前仍为数据库明文存储)
2. **业务模块**: 岗位、投递、聊天、评分等模块仅定义接口骨架 2. **业务模块**: 岗位、投递、聊天、评分等模块仅定义接口骨架
3. **RabbitMQ 监听**: 队列已定义但未实现消费者(Week 2+) 3. **RabbitMQ 监听**: 队列已定义但未实现消费者(Week 2+)
4. **Actuator 健康**: RabbitMQ 健康检查未配置(仅 ping) 4. **Actuator 健康**: RabbitMQ 健康检查未配置(仅 ping)

4
docs/api-v0.1.md

@ -24,7 +24,7 @@ Query: phone=13800000001, purpose=login
**限制:** **限制:**
- 60秒内同一手机号只能发送1次 - 60秒内同一手机号只能发送1次
- 5次错误后锁定5分钟 - 5次错误后锁定5分钟
- Mock环境验证码打印到日志 - Mock 环境验证码**不输出到日志**;本地按 `docs/frontend-integration.md` §3 的受控方式取得(生产禁止查表)
### 2. Mock 登录 ### 2. Mock 登录
``` ```
@ -171,7 +171,7 @@ http://localhost:8080/v3/api-docs
## 已知限制 (Week 1) ## 已知限制 (Week 1)
1. 短信验证码为 Mock 实现,验证码打印到日志,不发送真实短信 1. 短信验证码为 Mock 实现,应用日志**不输出验证码**,不发送真实短信;未接入真实供应商时发码返回“服务不可用”
2. 无企业注册/认证流程 2. 无企业注册/认证流程
3. 无岗位、投递、聊天、评分等业务功能 3. 无岗位、投递、聊天、评分等业务功能
4. 无 Token 刷新接口(Week 2+) 4. 无 Token 刷新接口(Week 2+)

23
docs/frontend-integration.md

@ -34,17 +34,22 @@ mvn spring-boot:run -Dspring-boot.run.profiles=dev
健康检查:`http://127.0.0.1:8080/api/v1/health` 返回 `status: UP`。 健康检查:`http://127.0.0.1:8080/api/v1/health` 返回 `status: UP`。
## 3. Mock 验证码日志位置 ## 3. 本地获取测试验证码(不通过日志)
开发环境短信为 Mock,验证码打印在**服务控制台/stdout 日志**中: **应用日志不会输出验证码**,也不得从控制台 / stdout / 日志文件中读取验证码。
``` 本地 dev/test 联调请使用受控方式取得验证码:
MOCK SMS CODE: phone=13800000002, purpose=login, code=483920
``` 1. 启动应用时显式开启 Mock 短信:`SMS_MOCK_ENABLED=true`(即 `app.sms.mock.enabled=true`)
2. 显式授权本次执行读取验证码表:`ALLOW_TEST_OTP_DB_READ=true`
3. 通过受控测试脚本或数据库内部只读查询取得验证码;脚本内部取码,不向终端展示
`docs/smoke-test.sh` 已按上述双重保护实现;两项条件任一不满足即输出 `test OTP retrieval is disabled` 并安全失败。
- 运行 `mvn spring-boot:run` → 看终端输出 **约束:**
- 后台运行 → 看重定向的日志文件(如 `logs/mydreamjob.log`) - 生产环境**禁止**查询验证码表辅助登录
- 每次发码后取**最新一条** `code=<6位数字>` - 当前尚未接入真实短信供应商;`app.sms.mock.enabled` 为 `false`、缺失、空值或非法值时,发码返回“服务不可用”,绝不返回虚假成功
- 验证码当前仍以**明文**存储于 `sms_verification_codes`,属已知遗留风险(未加密、未哈希)
## 4. Apifox 导入 OpenAPI ## 4. Apifox 导入 OpenAPI
@ -141,7 +146,7 @@ Authorization: Bearer <accessToken>
**登录方式**(开发环境 Mock): **登录方式**(开发环境 Mock):
1. `POST /auth/sms/send?phone=<手机号>` 发送验证码 1. `POST /auth/sms/send?phone=<手机号>` 发送验证码
2. 验证码打印在服务日志:`MOCK SMS CODE: ... code=xxxxxx` 2. 验证码**不再输出到日志**;本地按 §3 的受控方式取得(生产禁止查表)
3. `POST /auth/login` 携带 `{phone, code, purpose:"login"}` 获取 token 3. `POST /auth/login` 携带 `{phone, code, purpose:"login"}` 获取 token
## 7. 典型联调流程 ## 7. 典型联调流程

2
docs/offer-api.md

@ -259,7 +259,7 @@ POST /offers/{id}/remind
> 现网 OpenAPI 亦可访问 `GET /api/v1/v3/api-docs` 导出后导入 Apifox。 > 现网 OpenAPI 亦可访问 `GET /api/v1/v3/api-docs` 导出后导入 Apifox。
测试账号(Mock 短信验证码,验证码见应用日志): 测试账号(Mock 短信验证码:应用日志不再输出,本地按 `docs/frontend-integration.md` §3 的受控方式取得;生产禁止查表):
| 角色 | 手机号 | 用途 | | 角色 | 手机号 | 用途 |
|---|---|---| |---|---|---|

4
docs/openapi-rc.json

@ -1445,8 +1445,8 @@
"tags": [ "tags": [
"认证" "认证"
], ],
"summary": "发送短信验证码(Mock)", "summary": "发送短信验证码",
"description": "开发环境 Mock:验证码会打印到日志", "description": "开发/测试使用 Mock 发送实现;生产环境未接入短信网关时返回服务不可用。验证码不会写入日志。",
"operationId": "sendSmsCode", "operationId": "sendSmsCode",
"parameters": [ "parameters": [
{ {

2
docs/resume-api.md

@ -182,7 +182,7 @@ GET /resume-export-tasks/{taskId}/download
前置:启动基础设施 → 启动应用 → 导入 `docs/openapi-rc.json`(或 `GET /api/v1/v3/api-docs`)。 前置:启动基础设施 → 启动应用 → 导入 `docs/openapi-rc.json`(或 `GET /api/v1/v3/api-docs`)。
1. 登录学生账号(Mock 验证码,见日志)。 1. 登录学生账号(Mock 验证码:日志不再输出,本地按 `docs/frontend-integration.md` §3 的受控方式取得;生产禁止查表)。
2. `POST /profile/me/resume-versions`(multipart:`file` + `name=秋招版`)→ 200,`versionNo=1`。 2. `POST /profile/me/resume-versions`(multipart:`file` + `name=秋招版`)→ 200,`versionNo=1`。
3. 再次上传 → `versionNo=2`;设置 `name=社招版`。 3. 再次上传 → `versionNo=2`;设置 `name=社招版`。
4. `GET /profile/me/resume-versions` → 列表含两个版本。 4. `GET /profile/me/resume-versions` → 列表含两个版本。

483
docs/s01-independent-mysql-acceptance-20260911.md

@ -0,0 +1,483 @@
# S01 认证安全上线阻断整改 · 独立代码复验与真实 MySQL/Docker 黑盒验收报告
- **验收日期**:2026-09-11
- **验收性质**:独立验收(非功能开发);使用真实 MySQL 8、最新 JAR、真实 HTTP 请求
- **验收结论**:**FAIL**(已复现具体缺陷,S01 不可冻结)
- **报告文件**:`docs/s01-independent-mysql-acceptance-20260911.md`
- **验收产物目录**:`docs/acceptance/s01-20260911/`(脚本、请求体、日志、结果快照)
> 本报告不包含数据库密码、.env 内容、JWT、验证码、完整手机号、明文测试口令与非法 principal 敏感标记原文。
---
## 1. Git 基线
| 项 | 实测值 |
|---|---|
| 分支 | `fix/resume-upload-safety` |
| HEAD | `cdafbf8ac93544a3ef89d0f3014f822a0d575f8f`(docs: update README for latest delivery baseline) |
| 最近 5 次提交 | cdafbf8 / 277b3c0 / dbd27e6 / cf39737 / 498512d |
| 暂存区 | **空**(未执行 `git add`) |
| `git diff --check` | 无空白错误(仅 CRLF 提示) |
| Flyway 迁移改动 | **无**(V1–V41 原样,共 41 个迁移文件,最大版本 41) |
| 新增迁移 | **无** |
| 工作区修改 | 20 个(S01 8 个 + 简历上传安全 12 个,均未提交) |
| 未跟踪文件 | 基线 106 个,全部保留;本轮新增验收产物 |
| AGENTS.md | **未发现**(仓库根、上级目录、`.claude/` 均无 AGENTS.md/CLAUDE.md/GEMINI.md/.cursorrules) |
| `.env` | 保留(365B) |
**与 S01 无关的未提交修改(简历上传安全)已完整保护**:`profile/ProfileController.java`、`profile/ProfileService.java`、`profile/storage/LocalResumeStorageService.java`、`resume/ResumeVersionController.java`、`resume/ResumeVersionService.java`、`docs/frontend-integration.md`、`docs/resume-api.md` 及 5 个测试文件——本轮**未读取改写、未覆盖**,时间戳仍为 2026-09-10。
---
## 2. 独立代码审查结果(阶段 A)
### A1 验证码/手机号日志 —— **PASS**
| 检查项 | 结果 | 证据 |
|---|---|---|
| INFO/WARN/ERROR 不输出验证码 | ✅ | `SmsVerificationService` 仅 2 处 `log.warn`,参数为 `maskPhone(phone)`;`MockSmsCodeSender` 仅 1 条无参固定文本 |
| 不输出完整手机号 | ✅ | 实测 7 个验收手机号在全部日志中命中 **0** 次 |
| Mock 实现仅固定安全文本 | ✅ | `log.info("SMS verification code generated (mock sender)")` |
| Service 不把验证码传入日志参数 | ✅ | 全量检索无 `log.*code` 参数传递 |
| AuthService 不输出完整手机号 | ✅ | 仅 `log.info("Auto-created user: id={}")` |
| JWT 过滤器不记录 Token/subject/异常原文 | ✅ | 3 条固定文本,无 `e.getMessage()`、无 subject |
| 审计 details 不含完整手机号/验证码 | ✅ | 实测唯一 SMS_SEND 明细为 `phone=199****9999`(脱敏) |
| 异常消息不含敏感值 | ✅ | `SERVICE_UNAVAILABLE` 固定文本 `sms sender unavailable` |
| 不通过 toString/getMessage 间接泄露 | ✅ | 捕获的异常与堆栈均不含非法 principal 标记 |
**唯一敏感标记复验**(真实 Logback 捕获 + 真实运行日志双通道):标记手机号、真实验证码(3 个 DB 实际值)、JWT 前缀 `eyJ`、`Bearer ey`、mock 正文、非法 principal 标记、`NumberFormatException` —— 命中数**全部为 0**。
### A2 短信发送器装配 —— **PASS(3 种配置)+ 发现 1 处边界缺陷(见 F3)**
| 检查项 | 结果 |
|---|---|
| Service 只依赖 `SmsCodeSender` 接口 | ✅ 单构造参数注入,无 if 分支复制双流程 |
| `true` → 仅 `MockSmsCodeSender` | ✅ 实测(`SmsSenderConfigurationTest` 4/4 + 运行期行为) |
| `false` → 仅 `UnavailableSmsCodeSender` | ✅ 实测 |
| 缺失 → 默认 `UnavailableSmsCodeSender` | ✅ 实测(D3a) |
| 单一 Bean / 无装配歧义 | ✅ 三次启动均成功(缺/多 Bean 会直接启动失败) |
| mock=false 不回退 Mock | ✅ 实测无 mock 事件 |
| 生产不意外启用 Mock | ✅ `application.yml` 默认 `SMS_MOCK_ENABLED:false` |
| `@ConditionalOnProperty` 精确性 | ✅ Mock=`havingValue="true"`(**无** matchIfMissing);Unavailable=`havingValue="false", matchIfMissing=true`;name 与配置项 `app.sms.mock.enabled` 完全一致 |
### A3 mock=false 失败事务 —— **PASS(MySQL 回滚)+ 1 项非阻断观察(F4)**
- `@Transactional` 位于 `sendCode`;顺序:校验 → 锁定/限流 → 生成 → **`codeRepository.save`** → Redis 限流 → **`smsCodeSender.sendVerificationCode`** → 审计
- `BusinessException extends RuntimeException` → 默认回滚;发送异常**未被捕获**,不转换为空成功
- 真实 MySQL 实测:失败后 `sms_verification_codes` 完整字段前后**完全一致**(见第 6 节)
- 观察项:限流键在发送**之前**写入,失败后残留 60s(既有排序,非 S01 引入)→ F4
### A4 `CurrentUserAccessor` —— **PASS**
拒绝:null / `!isAuthenticated` / `AnonymousAuthenticationToken` / 空或空白 name / `anonymousUser` / 非 ASCII 纯数字(含 `+42`、`" 42"`、`"42 "`、Unicode 数字)/ Long 溢出 / `userId<=0`。
固定抛 `InsufficientAuthenticationException`(既有 `@ExceptionHandler(AuthenticationException)` → **HTTP 401**),**不回显、不记录**非法 principal,不新增 ErrorCode,不承担角色/归属判断,身份仅来自 SecurityContext。
### A5 `JwtAuthenticationFilter` —— **PASS**
合法数字 subject 正常认证;缺失 Authorization → 匿名;无效签名/过期 → 既有 401;非数字 subject → **401 而非 500**;日志无 Token/subject/异常原文;`JwtTokenProvider` 未改动(Token 兼容语义不变);**未声称修复 Refresh Token**。
### A6 `/jobs` 安全规则 —— **PASS**
`SecurityConfig:119` 由 `.requestMatchers("/jobs").permitAll()` 改为 `.requestMatchers(HttpMethod.GET, "/jobs").permitAll()`,且该规则**先于** `.requestMatchers("/jobs/**").authenticated()` 求值,写请求确实命中 authenticated。
**全部 /jobs 映射与最终权限(21 个 operation,真实 HTTP 实测)**
| 方法 | 路径 | 匿名 | STUDENT | COMPANY | ADMIN | 归属要求 |
|---|---|---|---|---|---|---|
| GET | `/jobs` | **200** | 200 | 200 | 200 | 无 |
| GET | `/jobs/search`、`/jobs/filters` | **200** | 200 | 200 | 200 | 无 |
| GET | `/jobs/public/{id}` | **200** | 200 | 200 | 200 | 无 |
| GET | `/jobs/{id}/reviews`、`/summary`、`/{reviewId}`、`/jobs/compare` | **200** | 200 | 200 | 200 | 无 |
| POST | `/jobs` | **401** | **403/2002** | **200** | **403/2002** | 需绑企业 |
| PUT | `/jobs/{id}` | **401** | 403/2002 | 200 | 403/2002 | **本企业**(他企业 400/7002) |
| PATCH | `/jobs/{id}/status` | **401** | 403/2002 | 200 | 403/2002 | **本企业**(他企业 400/7002) |
| DELETE | `/jobs/{id}` | **401** | — | — | — | 无该端点 |
| GET | `/jobs/{id}` | **401** | 403/2002 | 200 | 403/2002 | 本企业 |
| GET | `/jobs/{id}/match` | **401** | — | — | — | 登录 |
| GET | `/jobs/{id}/competition-ranking` | **401** | — | — | — | 登录 |
| POST/DELETE | `/jobs/{id}/favorite`、GET `/jobs/favorites` | **401** | — | — | — | 登录 |
| GET | `/jobs/{id}/personalized-review-score` | **401** | — | — | — | 登录 |
**不依赖 Controller 解析 anonymousUser 失败来保护写接口**:匿名请求在安全层即被拒(响应无 `requestId` 字段,符合既有 `AuthenticationEntryPoint` 固定载荷)。
### A7 剩余直接身份解析 —— **PASS(无 permitAll 可达)= 但存量 43 处**
- 全量检索(任意变量名):`Long.(valueOf|parseLong)(*.getName())` 共 **43 处**,分布于 43 个文件
- `.isAuthenticated()` 共 44 处;`anonymousUser` 字面量 5 处(`PersonalDataExportController`、`DeletionGuardInterceptor`×2、`AdminReviewReplyController`、`CompanyReviewManagementController`)
- **逐端点核验 permitAll 可达性**(按 SecurityConfig 匹配顺序,而非"路径要求认证"默认安全):
- permitAll 路径共 19 条规则,涉及的控制器为 `AuthController`、`CompanyController`、`JobReviewController`、`JobController`、`MatchController`、`JobRecommendationController`
- **匿名可达的 8 个 GET 方法**(`listJobs`、`searchJobs`、`getFilters`、`getPublicJob`、`list`、`get`、`summary`、`compare`)**均不调用身份解析**
- `CompanyController` 的 `resolveUserId` 仅被 `/companies/me`(POST/GET/PUT)使用 → authenticated;`JobReviewController` 的 `resolveUserId` 仅被 create/update/delete 使用 → authenticated
- **结论:43 处中 0 处处于 permitAll 可达路径**,不构成 S01 发布阻断。`JobController` 已改用 `CurrentUserAccessor`(裸解析 0 处)
- 存量风险提示:`DeletionGuardInterceptor:51` 使用 `chars().allMatch(isDigit)` 预检,可挡 `anonymousUser`,但对 **20 位纯数字溢出**仍会抛 `NumberFormatException`(需合法签名 Token 才能到达)→ 列为后续轮次加固项
### A8 OpenAPI 与响应兼容 —— **PASS**
- 路径/operation 数量:静态 173/211;**运行时 173/211**,一致
- 唯一变更:`docs/openapi-rc.json` 2 行描述文本(移除"验证码会打印到日志"表述),无结构变更
- 登录、发码、岗位请求/响应 Schema 未变(S01 未改任何 DTO)
- 无新增短信调试接口、无新增获取验证码接口
---
## 3. 自动化独立复验(阶段 B,实测重新执行)
| 命令 | Tests run | Failures | Errors | Skipped | 结果 |
|---|---|---|---|---|---|
| `-Dtest="SmsVerificationSecurityTest,SmsSenderConfigurationTest,CurrentUserAccessorTest,AuthenticationBoundaryIntegrationTest"` | **40** | 0 | 0 | 0 | BUILD SUCCESS |
| `-Dtest="*Auth*Test,*Security*Test,*Sms*Test,*Job*Test"` | **214** | 0 | 0 | 0 | BUILD SUCCESS |
| `mvn test` | **1658** | 0 | 0 | 0 | BUILD SUCCESS(3:45) |
| `mvn clean verify` | **1658** | 0 | 0 | 0 | **BUILD SUCCESS**(4:31,产出 JAR 79,965,063B) |
| `python ../docs/verify_openapi.py` | — | — | — | — | paths 173 / operations 211 / **All checks passed!** |
---
## 4. Docker / MySQL 环境(阶段 C)
**启动前发现**:`docker ps` 为空,三个业务容器处于 **Exited**(约 2 小时前,139/0/137 退出码),**volume 数据完好**。容器标签显示由 `Desktop\qinghe\backend-infra\compose.yaml` 创建(项目名 `backend-infra`)。
**本轮处置(非破坏性,已记录)**:仅执行 `docker start mydreamjob-mysql mydreamjob-redis mydreamjob-rabbitmq`——**未重建容器、未删除 volume、未清空数据库、未触碰其余 3 个无关孤立容器、未停止 Docker**。
| 项 | 实测值 |
|---|---|
| Docker Server | 29.6.2 |
| MySQL | **8.4.11**(`127.0.0.1:3307`,healthy) |
| Redis | 7.4-alpine(`6379`,healthy) |
| RabbitMQ | 3.13-management-alpine(`5672/15672`,healthy) |
| 验收数据库 | `mydreamjob` |
| Flyway | 41 条,**success=1 全部成功**,0 失败,最大版本 **41**(`41 message recall`) |
| S01 新增迁移 | **无** |
**已修复的验收环境问题(脚本层,非项目缺陷)**:Git Bash(MSYS) 会把裸 `/` 的 `RABBITMQ_VHOST` 自动改写为 Git 安装根路径,导致 `530 NOT_ALLOWED vhost not found`。经在验收脚本中清除该变量(交由 `application.yml` 默认值 `/` 承接)后,RabbitMQ 错误数 **0**。
---
## 5. 三种配置启动验证(阶段 D,均为最新 `mvn clean verify` JAR)
| 场景 | 启动 | SmsCodeSender 实际实现 | 发码行为 | 日志敏感值 | 判定 |
|---|---|---|---|---|---|
| **D1** `mock=true` | ✅ Started 26.5s,health 200,8080 监听 | **MockSmsCodeSender**(固定事件文本命中 1 次) | HTTP **200**(非 500) | 0 | **PASS** |
| **D2** `mock=false` | ✅ Started 26.4s | **UnavailableSmsCodeSender**(无 mock 事件) | HTTP **400** `{"code":400,"message":"sms sender unavailable"}` | 0 | **PASS** |
| **D3a** 属性完全缺失 | ✅ Started 27.5s | **UnavailableSmsCodeSender**(未回退 Mock) | HTTP **400** 同上 | 0 | **PASS** |
| **D3b** 属性存在但为空值 | ❌ **APPLICATION FAILED TO START** | **无 Bean** | — | — | **FAIL(F3)** |
每次启动均记录 profile(无 profile,基础 `application.yml`)、PID、端口、实现、Started 日志、health 状态;**仅停止本轮启动并记录 PID 的 JAR**(每次均以 `Get-CimInstance Win32_Process` 校验命令行确为 `zhilai/mydreamjob-server/target/*.jar`)。
---
## 6. 真实 MySQL 验证码事务回滚(阶段 E)
**mock=false(发送失败回滚)**——完整字段快照对比,非 COUNT(*):
| 指标 | BEFORE | AFTER | 结论 |
|---|---|---|---|
| 该手机号记录数 | 0 | 0 | ✅ |
| 全局 MAX(id) | 990 | 990 | ✅ 无新增 |
| 全局总行数 | 917 | 917 | ✅ |
| 未使用(used=0)记录数 | 0 | 0 | ✅ 无可用残留 |
| SMS_SEND 审计总数 | 819 | 819 | ✅ 无虚假成功审计 |
| 完整字段差异 | — | — | ✅ **前后完全一致** |
无半提交、无 attempts 异常、无错误锁定状态更新。HTTP **400**(非虚假成功)。
**mock=true(完整链路)**:
1. 发送 → HTTP 200,DB 生成 1 条记录(验证码值仅存于库内,报告不复制)
2. 从 DB 取码 → `POST /auth/login` → HTTP **200**,响应含 `accessToken/refreshToken/expiresIn/nickname/phone/role/userId`,响应内**不含验证码明文**
3. 登录后该记录 `used=1`、`attempts=0`(一次性消费语义不变)
4. **重放同一验证码 → HTTP 400**(拒绝)
5. 日志与审计仍不含验证码/完整手机号
6. 长度/有效期/最大尝试次数/锁定时间语义未变(`SmsVerificationSecurityTest` 10/10 覆盖)
---
## 7. 真实 HTTP 权限矩阵(阶段 F)
见第 2 节 A6 表格。关键断言全部成立:
- **匿名 POST /jobs = HTTP 401(`code=401`),不是 500** ✅
- STUDENT / ADMIN POST /jobs = **403 / 2002** ✅
- COMPANY POST /jobs = **200** ✅(生成 id=43)
- 他企业 PUT/PATCH = **400 / 7002**(既有语义,未擅改)✅
- 公开 GET(`/jobs`、`/search`、`/filters`、`/public/{id}`、`/{id}/reviews`)语义不变 = 200 ✅
- 每条请求均记录 method / path / HTTP / business code / requestId
**jobs 表副作用(完整字段对比,非 COUNT)**:
```
差异仅:新增 JOB:43|company_id=22|S01_ACC_*_job|PUBLISHED|...|requirements=S01_ACC_ok
TOTAL_JOBS 27 → 28
TAMPERED_TITLE=0 ← 他企业篡改尝试零落库
其余全部 JOB 行逐字段未变
```
---
## 8. 非法 Token / principal 黑盒(阶段 G)
对受保护端点 `POST /jobs` 的真实 HTTP 请求(含合法请求体):
| 用例 | HTTP | business code | 响应回显敏感标记 | 判定 |
|---|---|---|---|---|
| 无 Authorization | 401 | 401 | no | ✅ |
| 合法数字 subject(学生 7936) | **403** | **2002** | no | ✅ 正常进入既有权限判断 |
| `subject=anonymousUser` | **401** | 401 | no | ✅ |
| 受控非法 principal 标记 | **401** | 401 | no | ✅ 非 500 |
| subject 缺失(空) | 401 | 401 | no | ✅ |
| `subject=0` | 401 | 401 | no | ✅ |
| `subject=-1` | 401 | 401 | no | ✅ |
| subject 超出 Long 范围 | 401 | 401 | no | ✅ |
| 过期 Token | 401 | 401 | no | ✅ |
| 签名被篡改 | 401 | 401 | no | ✅ |
**无任何用例返回 500;响应与日志均未回显 subject 或 Token;jobs 表完整内容零变化(无业务写入)** ✅
Token 生成方式:验收专用 HS256 离线生成(复用项目 `JWT_SECRET` 环境变量,**未落盘、未输出**);外部 HTTP 无需伪造即可安全覆盖全部用例,故未降级为纯集成测试。
---
## 9. 日志与审计全量脱敏(阶段 H)
仅扫描**本轮**日志(D1/D1b/D2/D3a/D3b 的 app + stdout):
| 禁止项 | 命中 | 说明 |
|---|---|---|
| 验证码明文(3 个 DB 实际值) | **0** | |
| 完整手机号(7 个验收号) | **0** | |
| JWT(`eyJhbGci`、`Bearer ey`) | **0** | |
| Authorization 头值 | **0** | 16 处 `authorization` 均来自 D3b 启动失败堆栈的 `AuthorizationFilter.doFilter` |
| secret | **0** | |
| 非法 principal 标记 | **0** | |
| `NumberFormatException` 堆栈 | **0** | |
| Mock 短信正文 | **0** | `MOCK SMS CODE` 命中 0 |
| `password` | ⚠️ 16 | 全部为 Spring Boot 默认 `Using generated security password: <uuid>` → **F5(既有行为,见第 12 节)** |
**审计表(audit_logs)**:完整手机号命中 **0**、非法 principal 命中 **0**、Token 命中 **0**、脱敏手机号命中 **1**(唯一 SMS_SEND 明细 `phone=199****9999`)✅
**允许项**:脱敏手机号、固定事件名、requestId、固定 purpose 均正常。
---
## 10. 运行时 OpenAPI(阶段 I)
| 项 | 值 |
|---|---|
| 访问地址 | `GET /api/v1/v3/api-docs`(应用 `context-path=/api/v1`;裸 `/v3/api-docs` 返回 404 属预期) |
| HTTP | 200 |
| 运行时 paths / operations | **173 / 211** |
| 静态 paths / operations | 173 / 211 |
| 一致性 | **PASS** |
| 短信相关路径 | 仅 `/auth/sms/send`(无新增调试/取码接口) |
| `/jobs` 路径数 | 15 |
| `/auth/sms/send` 描述 | 已不再声称验证码输出到日志/控制台 ✅ |
---
## 11. 文档一致性核查(阶段 13)—— **FAIL**
**仍指导从日志读取验证码的文档(精确文件:行号)**:
| 文件 | 行号 | 内容(已脱敏) | 性质 |
|---|---|---|---|
| `docs/acceptance-week1.md` | 94 | `# Mock 登录(从日志获取验证码)` | 历史验收记录 |
| `docs/acceptance-week1.md` | 148 | 注: 验证码从应用日志中获取 | 历史验收记录 |
| `docs/acceptance-week1.md` | 152 | 验证码打印到日志,不发送真实短信 | 历史验收记录 |
| `docs/api-v0.1.md` | 27 | Mock 环境验证码打印到日志 | 历史 API 文档 |
| `docs/api-v0.1.md` | 174 | 短信验证码为 Mock 实现,验证码打印到日志 | 历史 API 文档 |
| `docs/frontend-integration.md` | 37,39,42,144 | "Mock 验证码日志位置"、"打印在服务控制台/stdout 日志中"、旧示例行 `MOCK SMS CODE: phone=138****0002, … code=******` | **前端接入文档(在途修改文件,本轮不可改)** |
| `docs/offer-api.md` | 262 | 测试账号(Mock 验证码,见日志) | 活文档 |
| `docs/resume-api.md` | 189 | 登录学生账号(Mock 验证码,见日志) | **在途修改文件** |
| **`docs/smoke-test.sh`** | **64-71** | **`sms_code()`:`grep -a "MOCK SMS CODE.*phone=$PHONE" $log \| … \| grep -oE 'code=[0-9]{6}'`** | **git 跟踪的既有可执行脚本(见 F1)** |
`README.md:167` 表述正确(禁止写入敏感信息),**不计入违规**;`docs/openapi-rc.json:1449` 已修正 ✅。
---
## 12. RabbitMQ 与外围终态(阶段 14)
| 项 | 实测值 |
|---|---|
| 队列 | `queue.sms.verification`、`queue.notification`、`queue.audit`、`queue.notification.dlq` |
| ready / unacked | **全部 0 / 0** |
| mock=false 失败后 notifications 增量 | **0** |
| mock=false 失败后 SMS_SEND 审计增量 | **0**(无虚假发送成功审计) |
| 匿名岗位写请求副作用 | jobs/notifications/audit 均无新增 |
| 无与 S01 无关的表变化 | ✅(仅本轮验收夹具,已精确清理) |
| 容器终态 | 3 容器仍 Up(healthy);**未停止 Docker** |
---
## 13. PASS / FAIL / SKIP 清单
| # | 验收项 | 结果 |
|---|---|---|
| A1 | 验证码/手机号不进入日志与审计 | **PASS** |
| A2 | SMS 发送器装配(true/false/缺失) | **PASS** |
| A2′ | 空值边界下的 Bean 唯一性 | **FAIL**(F3) |
| A3 | mock=false 失败事务回滚(真实 MySQL) | **PASS**(F4 为非阻断观察) |
| A4 | CurrentUserAccessor 边界 | **PASS** |
| A5 | JwtAuthenticationFilter 边界 | **PASS** |
| A6 | /jobs 匹配顺序与权限矩阵 | **PASS** |
| A7 | 无 permitAll 可达的直接身份解析 | **PASS**(存量 43 处均不可匿名到达) |
| A8 | OpenAPI 与响应兼容 | **PASS** |
| B | 自动化复验(40/214/1658/clean verify) | **PASS** |
| C | Docker/MySQL/Flyway | **PASS** |
| D | 三种配置启动 | **PASS**(D1/D2/D3a)|
| E | 真实 MySQL 回滚 + mock=true 链路 | **PASS** |
| F | 真实 HTTP 权限矩阵 | **PASS** |
| G | 非法 Token/principal 黑盒 | **PASS** |
| H | 日志与审计脱敏(`password` 项除外) | **PASS** |
| I | 运行时 OpenAPI | **PASS** |
| 13 | 文档不再指导从日志读取验证码 | **FAIL**(F1、F2) |
| 14 | RabbitMQ 与外围绕 | **PASS** |
| — | SKIP | **0**(核心验收项无 SKIP) |
**FAIL 计数:3**(F1、F2、F3)
---
## 14. 缺陷详情(可复现)
### F1(阻断)项目自带冒烟脚本因 S01 改动而失效
- **文件/方法**:`docs/smoke-test.sh` → `sms_code()`(第 64-71 行,git 跟踪,提交 `b01616d`)
- **缺陷**:该函数依赖被 S01 删除的日志行 `MOCK SMS CODE: phone=…, code=…` 提取验证码
- **复现**:
```bash
LOG=<本轮 mock=true 应用日志>
grep -a "MOCK SMS CODE.*phone=<验收手机号>" "$LOG" | tail -n 1 | grep -a -oE 'code=[0-9]{6}' | cut -d= -f2
```
- **实际结果**:输出为空 → `SMS=""` → 后续登录步骤拿不到验证码,冒烟流程中断
- **期望结果**:脚本应从数据库/受控夹具取得验证码(与 S01 新语义一致)
- **影响**:既有可执行验收工具**功能性回归**;S01 整改报告将其列为"文档遗留"但未识别此处为**可执行依赖**
### F2(阻断,对应验收标准第 17 项)文档仍指导从日志读取验证码
- **文件/行号**:见第 11 节表格(`acceptance-week1.md:94,148,152`、`api-v0.1.md:27,174`、`frontend-integration.md:37,39,42,144`、`offer-api.md:262`、`resume-api.md:189`)
- **实际结果**:文档仍声称"验证码打印到日志/控制台"并指导据此取码
- **期望结果**:明确"日志不再输出验证码";dev/test 通过**受控测试夹具或数据库内部验收方式**取码;禁止生产查询验证码表完成登录;说明 `mock=false` 且无真实供应商时返回服务不可用;真实短信供应商待单独接入
- **约束**:`docs/frontend-integration.md`、`docs/resume-api.md` 属**在途修改文件**,本轮不得改动
### F3(非阻断但违反 A2 明确要求)空值配置导致启动失败
- **文件**:`auth/sms/MockSmsCodeSender.java`(`@ConditionalOnProperty(name="app.sms.mock.enabled", havingValue="true")`)与 `auth/sms/UnavailableSmsCodeSender.java`(`havingValue="false", matchIfMissing=true`)
- **缺陷**:属性**存在但为空值**(如 env 中 `SMS_MOCK_ENABLED=`)时,两个条件都不匹配 → **0 个 `SmsCodeSender` Bean**
- **复现**:`java -jar target/mydreamjob-server-*.jar --app.sms.mock.enabled=` (其余同 D2)
- **实际结果**:
```
APPLICATION FAILED TO START
Parameter 3 of constructor in com.mydreamjob.auth.SmsVerificationService required
a bean of type 'com.mydreamjob.auth.sms.SmsCodeSender' that could not be found.
```
- **期望结果**:任何取值(含空值/空白)都应至少装配一个实现,绝不因缺少 Bean 以模糊 Spring Bean 错误启动失败(原工作令第 11 条)
- **影响**:部署可用性风险(空 env 变量在生产会致全站不可启动);未造成数据泄露
### F4(观察,非阻断)失败后残留限流键
- 现象:mock=false 发送失败后 `sms:rate:<phone>` 残留(TTL 60s),60s 内重试返回 `code=400 sms rate limited` 而非 `sms sender unavailable`
- 定性:**既有排序**(限流键在发送前写入),S01 未改变该顺序;MySQL 无残留有效验证码;不属冻结阻断项
- 建议:后续轮次评估失败时释放限流键或调整写入顺序
### F5(观察,非阻断,既有行为)Spring Boot 默认安全口令入日志
- 现象:日志出现 `Using generated security password: <uuid>`
- 定性:Spring Boot `UserDetailsServiceAutoConfiguration` 默认行为;**经历史日志比对确认 S01 之前即存在**(`mydreamjob-server/logs/backend-startup.log` 等)
- 可利用性:应用未启用 httpBasic/formLogin,该口令无 HTTP 认证入口消费 → 不可用于登录
- 建议:后续轮次排除 `UserDetailsServiceAutoConfiguration` 或显式声明 `AuthenticationManager`
---
## 15. 剩余风险
1. **F1/F2 未修复前,开发/测试人员仍会按文档从日志找验证码 → 必然失败并误判"系统故障"**
2. 存量 43 处直接身份解析(均不可匿名到达,但缺乏统一 `CurrentUserAccessor` 收口);`DeletionGuardInterceptor` 对 20 位数字溢出仍可能 500
3. 验证码仍以明文存储于 `sms_verification_codes.code`(本轮未改表结构,属已知遗留)
4. 真实短信供应商未接入;生产 `mock=false` 下所有发码请求均安全失败(设计如此,但需产品确认上线策略)
5. 安全层 401 响应不含 `requestId`(既有 `AuthenticationEntryPoint` 固定载荷),与业务层错误响应格式不一致,不利于链路追踪
6. 本轮验收脚本依赖 Git Bash 环境变量语义(vhost 转换问题已规避并记录)
---
## 16. 是否允许冻结 S01
**不允许冻结。**
自动化指标(1658/1658、clean verify、静态与运行时 OpenAPI 173/211)全部达成,核心安全目标(日志脱敏、`SMS_MOCK_ENABLED` 生效、匿名 POST /jobs 401、非法 subject 401 不回显、真实 MySQL 回滚)**均已独立复验通过**;但存在 3 项 FAIL(F1 可执行脚本回归、F2 文档违规指导、F3 空值启动失败),其中 F2 直接命中验收标准第 17 项,F1 为可执行功能回归。
---
## 17. S01 下一轮定向整改指令
### R1 —— 修复冒烟脚本取码方式(对应 F1)
- **文件**:`docs/smoke-test.sh`
- **方法**:`sms_code()`
- **错误数据/逻辑**:`grep -a "MOCK SMS CODE.*phone=$PHONE" "$log_path" | tail -n 1 | grep -a -oE 'code=[0-9]{6}' | cut -d= -f2`
- **复现请求**:`bash docs/smoke-test.sh`(mock=true 环境,日志中已无该行)
- **实际结果**:返回空字符串,登录步骤失败
- **期望结果**:改为受控来源取码,二选一——
1. 数据库内部查询(推荐,仅限本地/测试):
`SELECT code FROM sms_verification_codes WHERE phone=? AND purpose='login' AND used=0 ORDER BY id DESC LIMIT 1;`
2. 或由测试夹具直接注入固定验证码记录后读取
- **必须新增的失败测试**:新增独立脚本断言 `sms_code()` 在"日志不含 `MOCK SMS CODE`"时仍能返回 6 位码(当前实现必然失败)
- **禁止的绕过**:不得恢复 `MOCK SMS CODE` 日志行;不得让脚本读取生产日志取码
### R2 —— 修正文档中的取码指引(对应 F2,命中验收标准第 17 项)
- **文件与行号**:`docs/acceptance-week1.md:94,148,152`;`docs/api-v0.1.md:27,174`;`docs/offer-api.md:262`;`docs/frontend-integration.md:37,39,42,144`;`docs/resume-api.md:189`
- **错误内容**:声称"验证码打印到日志/控制台"并指导据此取码(含旧示例行)
- **复现**:`rg -n "控制台|日志.*验证码|MOCK SMS|验证码.*日志|查看验证码" docs README.md`
- **实际结果**:上述 10 处仍存在
- **期望结果**:统一替换为——
- 日志不再输出验证码;
- dev/test 环境通过**受控测试夹具或数据库内部验收方式**取得验证码;
- **禁止生产环境查询验证码表完成登录**;
- `mock=false` 且未接入真实供应商时返回"服务不可用",绝不返回虚假成功;
- 真实短信供应商仍待单独接入。
- **协调要求**:`docs/frontend-integration.md`、`docs/resume-api.md` 属**简历上传安全**在途修改文件,需与该项变更方**合并处理**,避免互相覆盖
- **必须新增的失败测试**:新增文档门禁测试(或 CI 检查)断言 `docs/**` 与 `README.md` 不再匹配 `MOCK SMS CODE|验证码.*打印.*日志|控制台.*验证码|见日志.*验证码`
- **禁止的绕过**:不得删除或改写为同义表述;不得仅在 README 声明而保留违规正文
### R3 —— 空值配置不再导致启动失败(对应 F3)
- **文件**:`auth/sms/MockSmsCodeSender.java`、`auth/sms/UnavailableSmsCodeSender.java`(必要时新增无条件兜底实现)
- **错误配置**:`app.sms.mock.enabled=`(存在但空)
- **复现请求**:`java -jar target/mydreamjob-server-0.0.1-SNAPSHOT.jar --app.sms.mock.enabled=`(DB/Redis/RabbitMQ 同 D2)
- **实际结果**:`APPLICATION FAILED TO START … required a bean of type 'SmsCodeSender' that could not be found`
- **期望结果**:启动成功并装配 `UnavailableSmsCodeSender`(空值、空白值、缺失、`false` 四者行为一致;仅显式 `true` 才装配 Mock)
- **必须新增的失败测试**(`SmsSenderConfigurationTest` 内新增,不改动既有断言):
- `emptyPropertyStillProvidesUnavailableSender`
- `blankPropertyStillProvidesUnavailableSender`
- `exactlyOneSenderBeanForEveryValueIncludingEmpty`
- **禁止的绕过**:不得让 Mock 成为 `matchIfMissing` 默认;不得用 `@Primary` 掩盖歧义;不得让应用以空 Bean 启动(必须真实拒绝发码,不得伪造成功)
### R4 —— 非阻断建议(可与上轮合并,不单独阻断冻结)
1. `DeletionGuardInterceptor:60` 增加溢出安全解析(复用 `CurrentUserAccessor` 语义)
2. 评估失败时释放 `sms:rate:<phone>` 限流键(F4)
3. 排除 `UserDetailsServiceAutoConfiguration` 或显式声明 `AuthenticationManager`(F5)
4. 统一为安全层 401 响应补充 `requestId`(便于链路追踪)
---
## 18. 验收产物清单(`docs/acceptance/s01-20260911/`)
| 文件 | 用途 |
|---|---|
| `launch.sh` | 三种配置启动器(true/false/MISSING/EMPTY) |
| `gen-token.js` | 验收专用 HS256 Token 离线生成(密钥仅取自环境变量,不落盘) |
| `matrix.sh` / `matrix-results.txt` | 阶段 F 权限矩阵脚本与结果 |
| `phase-g.sh` / `phase-g-results.txt` | 阶段 G 非法 Token 边界脚本与结果 |
| `snapshot-jobs.sql` / `jobs-before.txt` / `jobs-after.txt` / `jobs-before-g.txt` / `jobs-after-g.txt` | jobs 表完整字段快照与对比 |
| `snapshot-sms.sql` / `before-mockfalse.txt` / `after-mockfalse.txt` | 阶段 E 验证码表回滚对比 |
| `job-body.json` / `tamper-body.json` / `status-body.json` / `ok-body.json` | 请求体夹具(ASCII,避免 shell 编码问题) |
| `d1*/d2*/d3*` 日志 | 三次配置启动的应用日志与 stdout |
| `runtime-openapi.json` | 运行时 OpenAPI 快照 |
| `acc-fixture.txt` / `acc-prefix.txt` | 验收夹具 ID 记录(已清理) |
| `sensitive-markers.txt` | 敏感标记清单(内容未写入报告) |
**验收数据清理**:按精确 ID 与前缀删除 users(7933-7937)/companies(22,23)/jobs(42,43)/sms_verification_codes(1),Redis 验收键已清理;**保留**本轮窗口内 2 条 audit_logs(append-only 合规记录,不做删除);`jobs` 总数回到基线 26。清理前后均确认目标归属,无模糊 DELETE。
---
**已复现具体缺陷,S01不可冻结;验收报告已包含下一轮定向整改指令。**

288
docs/s01-independent-mysql-acceptance-20260914.md

@ -0,0 +1,288 @@
# S01 最终真实 MySQL / Docker 增量复验报告(脱敏版)
- **验收日期**:2026-09-14
- **分支**:`fix/resume-upload-safety`
- **范围**:只验证 S01 第四轮整改(F6:`expires_at` → `expired_at`)后的**真实运行路径**;本轮**未修改任何代码**
- **结论**:**PASS**
- **本报告不含**:验证码值、完整手机号、JWT、`Authorization` 头、数据库口令、`.env` 内容
> 本轮为此前因 Docker Linux Engine 未运行而 BLOCKED 的复验。本次 Docker Engine 可用,全部真实黑盒已执行完成。
---
## 1. 基线与边界
| 项 | 实测 |
|---|---|
| 分支 | `fix/resume-upload-safety` |
| 暂存区 | **空**(`git diff --cached --name-only` 无输出) |
| `git diff --check` | 无空白错误(仅 CRLF 提示) |
| 迁移改动 | **无**(`git diff --name-only -- db/migration` 无输出;41 个文件,最大 `V41__message_recall.sql`) |
| 已跟踪改动 | 24 个(**全部来自此前几轮**:7 个 docs + 11 个 java main + 6 个 java test),本轮 **0 新增** |
| 删除文件 | **0** |
| 历史未跟踪文件 | 未删除、未移动、未覆盖 |
本轮**未修改**:生产代码、现有测试、`docs/smoke-test.sh`、`V1–V41`;**未新增** Flyway/表/索引/`ErrorCode`/接口;**未恢复**验证码日志。
## 2. Docker 环境
| 项 | 值 |
|---|---|
| Docker Engine | 29.6.2(Docker Desktop 4.84.0,linux/amd64) |
| `mydreamjob-mysql` | **healthy**(mysql:8.4,127.0.0.1:3307→3306) |
| `mydreamjob-redis` | **healthy**(redis:7.4-alpine) |
| `mydreamjob-rabbitmq` | **healthy**(rabbitmq:3.13-management-alpine) |
进入本轮时三个容器均为 `Exited`(2 天前停止)。经负责人明确授权后,对**已存在的具名容器**执行 `docker start`:**未重建容器、未改动配置、未触碰 volume(`mysql_data`/`redis_data`/`rabbitmq_data` 均保留)**。验收结束后容器保持运行,由负责人决定是否停止。
## 3. 真实 Schema 对账
真实 MySQL 执行 `SHOW COLUMNS FROM sms_verification_codes`(**应用账号 + `MYSQL_PWD`,非 root**):
```
id phone code purpose attempts max_attempts expired_at used created_at
```
三方对账**完全一致**:
| 来源 | 列集合 |
|---|---|
| 真实 MySQL | `attempts code created_at expired_at id max_attempts phone purpose used` |
| 实体 `@Column` | 同上 |
| `V1__base_schema.sql` | 同上 |
- **包含 `expired_at`** ✅
- **不包含 `expires_at`** ✅
**反证**:`SELECT expires_at FROM sms_verification_codes LIMIT 1;` → `ERROR 1054 (42S22) Unknown column 'expires_at' in 'field list'`(退出码 1)。
## 4. 真实取码 SQL
修复后的等价只读查询在真实 MySQL 执行:
```sql
SELECT code FROM sms_verification_codes
WHERE phone = ? AND purpose = ? AND used = 0
AND expired_at > NOW()
ORDER BY created_at DESC, id DESC LIMIT 1;
```
**退出码 = 0,无 `ERROR 1054`**。(查询返回行数按参数而定,本报告不展示任何取回的 `code`。)
## 5. 最新 JAR 启动
| 项 | 值 |
|---|---|
| JAR | `target/mydreamjob-server-0.0.1-SNAPSHOT.jar`(`mvn clean verify` 产物) |
| 启动参数 | `--spring.jpa.hibernate.ddl-auto=validate`、`--app.sms.mock.enabled=true`、`--server.port=8080` |
| 启动结果 | **`Started MyDreamjobApplication`(14.9 s)** |
| Flyway | 无错误(仅一条 MySQL 8.4 版本提示 WARN) |
| Schema validation | **无错误** |
| `GET /api/v1/health` | **HTTP 200,`status=UP`** |
| 运行时 OpenAPI | **173 paths / 211 operations** |
端口 8080 在启动前空闲,无未知进程被停止。日志写入本轮新目录 `docs/acceptance/s01-20260914/`,未覆盖历史日志。
## 6. 真实完整冒烟链
以唯一虚构前缀 `1990914xxxx` 与隔离 requestId 执行:
```bash
SMS_MOCK_ENABLED=true ALLOW_TEST_OTP_DB_READ=true bash docs/smoke-test.sh
```
结果:**PASS=10 FAIL=0 SKIP=1,exit=0**
| 步骤 | 结果 |
|---|---|
| `GET /health` | PASS(200) |
| `POST /auth/sms/send` + `POST /auth/login` | **PASS**(200 / 200) |
| `GET /jobs?size=5` | PASS |
| `GET /jobs/public/{id}` | PASS |
| `GET /jobs/{id}/match` | PASS |
| `POST /applications` | PASS |
| `PATCH /applications/{id}/status` | **SKIP**(未提供 `COMPANY_TOKEN`,属脚本既有语义) |
| `GET /notifications` | PASS |
| `GET /notifications/unread-count` | PASS |
| `POST /notifications/{id}/read` | PASS |
| `POST /applications/{id}/withdraw` | PASS |
逐项确认:
1. 发码成功 ✅
2. `smoke-test.sh` 经**真实 `docker exec`** 连真实 MySQL ✅
3. 使用应用账号 `MYSQL_USER`,**未使用 root** ✅
4. mysql argv **不含密码** ✅(见 §7)
5. SQL 使用 `expired_at` ✅
6. 成功取得可用验证码,**未显示其值** ✅
7. 真实 `POST /auth/login` 成功 ✅
8. 获得 Access Token,**未输出** ✅
9. 后续岗位/投递/通知步骤按夹具条件执行 ✅
10. **未出现 `ERROR 1054`** ✅
11. 未出现空验证码 ✅
12. 未从日志读取验证码 ✅
13. stdout/stderr 不含验证码、完整手机号、JWT、`Authorization`、数据库口令 ✅
**核心链路无 SKIP**:唯一 SKIP 是企业状态步骤,取码与登录核心链路均实际执行并通过。
**真实库侧证据**:该手机号在 `sms_verification_codes` 中产生 **1 行、`used=1`、`code` 长度 6** —— 真实 MySQL 中确实生成了 6 位验证码并被真实登录消费。
> **关键推论**:登录成功即证明真实执行的 SQL 使用了**有效列**。若仍是 `expires_at`,查询会报 `ERROR 1054`、取不到码,登录必然失败。
### `bash -x` 复验
以 `bash -x` 执行(新手机号):**exit=0,PASS=10 FAIL=0 SKIP=1**,捕获 stdout+stderr 后扫描,敏感命中数:
| 标记 | 命中 |
|---|---|
| 实际验证码值 | **0** |
| 完整手机号 | **0** |
| `Authorization: Bearer` | **0** |
| `LOGIN_BODY=` | **0** |
| JWT 形态(`eyJ…`) | **0** |
| `ERROR 1054` | **0** |
## 7. F5 真实环境 argv 证据
在真实查询执行期间,读取**容器内 mysql 进程的实际 argv**:
```
mysql -umydreamjob -N -B mydreamjob
```
- 使用**应用账号**(非 root)✅
- **不含 `-p<密码>`** ✅
- **不含 `--password`** ✅
- 密码经 `MYSQL_PWD` 环境变量传递 ✅
## 8. mock=false 真实 MySQL 回滚
以 `--app.sms.mock.enabled=false` 重启后调用 `POST /auth/sms/send`:
| 项 | 结果 |
|---|---|
| 响应 | `HTTP 400` + `{"code":400,"message":"sms sender unavailable"}` |
| 是否虚假成功 | **否** ✅ |
| 验证码表前后状态 | **完全一致**(该手机号 0 行;全局 `max_id` 994→994;总数 918→918) ✅ |
| 残留 `used=0` 验证码 | **无** ✅ |
| 新增虚假发送成功审计 | **0** 行 ✅ |
| 日志含手机号/验证码 | **0** 命中 ✅ |
响应与上一轮(`docs/acceptance/s01-20260911/d2-sms-response.txt`)逐字一致。
## 9. 配置缺失 / 空值 / 非法值启动
| 用例 | 启动 | Bean 缺失错误 | 发码响应 | 是否落库 |
|---|---|---|---|---|
| 配置**缺失** | `Started` ×1 | **0** | 400 `sms sender unavailable` | 未落库(994→994) ✅ |
| `SMS_MOCK_ENABLED=`(**空值**) | `Started` ×1 | **0** | 400 `sms sender unavailable` | 未落库(994→994) ✅ |
| `SMS_MOCK_ENABLED=invalid-value`(**非法值**) | `Started` ×1 | **0** | 400 `sms sender unavailable` | 未落库(994→994) ✅ |
**「恰好一个 `SmsCodeSender` 且为 `UnavailableSmsCodeSender`」在真实环境按排除法成立**:
- 0 个 Bean → 启动会以 `NoSuchBeanDefinition` 失败;≥2 个 → 会以 `NoUniqueBeanDefinition` 失败;Mock 生效 → 发码成功且落库。
- 实测:**启动成功** + **发码不可用** + **未落库**,三者互斥地锁定「恰好一个 `UnavailableSmsCodeSender`」。
(Bean 数量与类型的严格断言另由 `SmsSenderConfigurationTest`(真实 `ApplicationContextRunner`)在 §10 覆盖。)
每次只停止本轮明确记录 PID 的 JAR,未触碰任何未知进程。
## 10. 日志脱敏扫描
扫描对象:**本轮全部新日志**(6 个应用日志 + stdout)**+ 冒烟 stdout/stderr**。
| 禁止项 | 命中 |
|---|---|
| 实际验证码(本轮真实产生的 2 个,逐个比对) | **0 / 0** |
| 完整手机号(6 个测试号逐个比对) | **0** |
| `Authorization: Bearer` | **0** |
| JWT 形态(`eyJ…`) | **0** |
| `MYSQL_PASSWORD` 实际值 / 数据库口令 | **0** |
| `LOGIN_BODY` | **0** |
| `MOCK SMS CODE` | **0** |
| `NumberFormatException` | **0** |
| `ERROR 1054` | **0** |
**敏感标记命中合计 = 0。**
扫描器有效性对照:注入探针(`MyDreamJob backend smoke test`)命中 **2**,证明扫描器在工作,而非空转的 0。
## 11. OpenAPI 与回归
| 项 | 结果 |
|---|---|
| `python docs/verify_openapi.py` | **All checks passed!** |
| 运行时 `/v3/api-docs` | **173 paths / 211 operations** |
| 静态 `docs/openapi-rc.json` | **173 paths / 211 operations** |
| 静/运行时一致 | **YES**(路径差集:静态独有 = 无,运行独有 = 无) |
| 是否新增取码接口 | **无**(含 "code" 的唯一路径是 `/admin/scoring/risk/rules/{ruleCode}`,为风控规则码,与验证码无关) |
| 接口结构变化 | **无** |
Maven 安全专项:
```
mvn -Dtest="SmsSenderConfigurationTest,SmsVerificationSecurityTest,SmsVerificationServiceTest,CurrentUserAccessorTest,AuthenticationBoundaryIntegrationTest,SmsEmptyPropertyStartupIntegrationTest,SmsUnavailableSenderRollbackIntegrationTest" test
```
→ **Tests run: 61, Failures: 0, Errors: 0, Skipped: 0 — BUILD SUCCESS**
## 12. 数据清理
本轮产生的数据可按唯一虚构前缀 `1990914` 精确圈定(时间戳均为 2026-09-14):
| 表 | 行数 |
|---|---|
| `users` | 2(验收虚构号码:`199****0101`、`199****0102`) |
| `sms_verification_codes` | 2 |
| `applications` | 2(均为 `WITHDRAWN`) |
| `notifications` | 4 |
| `user_roles` | 2 |
| `audit_logs` | 8 |
**决策:保留本轮数据,不执行删除。** 理由:
1. `audit_logs` 有 **8 行** `user_id` 指向这两个用户,且该表**无外键约束**;删除用户会使审计行成为孤儿。
2. 本轮任务边界明确**禁止删除 `audit_logs` 行**。
3. 保留可维持审计链一致;边界亦明确允许「无法安全确认时保留数据并记录」。
未执行任何模糊 `DELETE`、未清空表、未重置自增、未修改真实账号。上述足迹为精确记录,供后续人工处置。
## 13. 最终 Git 检查
| 项 | 结果 |
|---|---|
| `git status --short` | 24 个已跟踪改动(**与本轮开始时同一批**)+ 98 个未跟踪(既有残骸 + 本轮验收产物) |
| `git diff --cached --name-only` | **空**(未暂存) |
| `git diff --check` | 无空白错误 |
| `git diff --name-only -- db/migration` | **空**(V1–V41 未修改) |
| 删除文件 | **0** |
| commit / push | **未执行** |
| S02 | **未进入** |
## 14. 验收结论
| # | 判据 | 结果 |
|---|---|---|
| 1 | Docker/MySQL 可用 | ✅ |
| 2 | 真实表包含 `expired_at` | ✅ |
| 3 | 真实取码 SQL 执行成功 | ✅ |
| 4 | 完整学生登录链成功 | ✅ |
| 5 | `bash -x` 无敏感泄露 | ✅ |
| 6 | mock=false 真实 MySQL 完整回滚 | ✅ |
| 7 | 缺失/空/非法配置安全回退 | ✅ |
| 8 | 运行时 OpenAPI 为 173/211 | ✅ |
| 9 | 无日志泄露 | ✅ |
| 10 | 核心项无 SKIP | ✅ |
| 11 | FAIL = 0 | ✅ |
**S01独立代码复验和真实MySQL/Docker黑盒全部通过,可以进入精确暂存检查点。**
---
## 遗留与观察(不阻断本轮结论)
1. **验证码仍为数据库明文存储**(未改表结构,属已知遗留)。
2. **真实短信供应商未接入**;生产 `mock=false` 下发码一律安全失败,需产品确认上线策略。
3. **本轮测试账号保留在库**(见 §12),与其审计记录一致;如需清理应由人工按精确 ID 处置。
4. `/api/v1/api-docs` 返回 500(`/api/v1/v3/api-docs` 正常 200)。该行为与 S01 无关,非本轮引入,记录待查。
5. 三个容器在验收结束后**保持运行**(本轮仅 `docker start`,未重建、未删除 volume),是否停止由负责人决定。

786
docs/s01-remediation-f1f2f3-20260911.md

@ -0,0 +1,786 @@
# S01 定向整改报告(F1 / F2 / F3)
- **整改日期**:2026-09-11
- **分支**:`fix/resume-upload-safety`
- **范围**:仅修复独立验收确认的 F1、F2、F3,并完成对应自动化与干净构建
- **本报告不包含**:数据库口令、.env 内容、JWT、验证码值、完整手机号、明文测试口令、非法 principal 敏感标记原文
- **整改期间状态**:`S01独立验收未通过,定向整改中。`
---
## 1. F1 的修复方式
`docs/smoke-test.sh` 的 `sms_code()` **彻底删除**了对应用日志的依赖(grep `MOCK SMS CODE`),改为**双重保护 + 容器内部只读 SQL 查询**。
同时删除:`SERVER_LOG` 变量、`resolve_log()` 函数、全部日志 grep。未恢复任何验证码日志。
| 项 | 整改前 | 整改后 |
|---|---|---|
| 取码来源 | `grep -a "MOCK SMS CODE.*phone=$PHONE" <日志>` | 容器内 `SELECT code FROM sms_verification_codes …` |
| 前置授权 | 无 | `SMS_MOCK_ENABLED=true` **且** `ALLOW_TEST_OTP_DB_READ=true` |
| 取码返回值 | stdout(进入 command substitution) | `REPLY` 变量约定(不打印) |
| 请求体传参 | `-d "<含验证码>"`(进入进程参数) | `--data-binary @-`(经 stdin) |
| 凭据处理 | 需 `SERVER_LOG` | 容器自身 `MYSQL_ROOT_PASSWORD` / `MYSQL_DATABASE`(不在本机命令行拼写口令) |
| 清理 | 无 | `unset REPLY/SMS/LOGIN_BODY` + `trap cleanup_secrets EXIT` |
脚本仍为 Bash,兼容当前 Git Bash;`bash -n` 语法校验通过。
## 2. smoke-test.sh 的新取码机制
```
sms_code()
├─ require_mock_sms_mode() # SMS_MOCK_ENABLED 必须为 true
├─ require_test_otp_db_read_permission() # ALLOW_TEST_OTP_DB_READ 必须为 true
├─ 校验 PHONE 为 11 位纯数字(防注入)
├─ 记录并关闭 xtrace(set +x),执行后精确恢复原状
├─ IFS= read -r code < <(otp_db_query ...) # 不使用 command substitution 取值
├─ 校验 code 为 6 位数字(当前 code-length / CODE_PATTERN)
├─ 失败 → unset code + 固定提示 + return 1(绝不携带空值继续登录)
└─ 成功 → REPLY="$code"; unset code; return 0
```
`otp_db_query()` 关键点:
- **容器名不硬编码**:`compose_mysql_container()` 用 `awk` 解析项目实际 `backend-infra/compose.yaml` 中 `mysql` 服务的 `container_name`;用 `BASH_SOURCE` 定位自身目录(被 `source` 时同样正确)
- SQL 经 **stdin**(heredoc 风格 `printf | docker exec -i` ),不出现在任何进程参数中
- 查询条件完整:`phone` + `purpose` + `used = 0` + `expired_at > NOW()`
(更正:本行原写作 `expires_at`,该列在 `sms_verification_codes` 中**不存在**,是 F6 记录的错误字段名;已于第四轮一并更正,详见文末「第四轮定向整改(F6)」)
- `ORDER BY created_at DESC, id DESC LIMIT 1`,只取 `code` 列
- 不使用 `curl -v`;失败诊断不打印请求体
## 3. 为什么不会把验证码输出到终端 / 日志 / 文件
1. 取码函数**从不 `echo`/`printf` 验证码**,只写 `REPLY`;调用方 `SMS="$REPLY"` 后立即 `unset REPLY`
2. `set +x` 包住取值与查询,且**按调用前状态精确恢复**,不无条件开启 xtrace
3. 登录请求体经 `--data-binary @-` 从 stdin 送入 curl,验证码不进入 argv
4. 请求体只在 `LOGIN_BODY` 局部变量中存在,用完 `unset`;**不落盘**
5. `trap cleanup_secrets EXIT` 兜底清理 `OTP/REPLY/LOGIN_BODY`
6. 脚本控制台只输出 `PHONE=$(mask_phone "$PHONE")`(脱敏为 `199****9999`),不打印完整手机号
7. 应用侧本就不输出验证码(S01 已整改),Mock 发送器仅输出固定文本
## 4. `ALLOW_TEST_OTP_DB_READ` 的强制条件
```bash
is_true() { v=$(printf '%s' "${1:-}" | tr -d '[:space:]' | tr '[:upper:]' '[:lower:]'); [ "$v" = "true" ]; }
require_mock_sms_mode() { is_true "${SMS_MOCK_ENABLED:-}"; }
require_test_otp_db_read_permission() { is_true "${ALLOW_TEST_OTP_DB_READ:-}"; }
```
- 两者同时为 `true`(大小写/空白不敏感)才允许查询
- 任一不满足:立即返回非 0,输出固定提示 `test OTP retrieval is disabled`,**不执行任何 docker/SQL 调用**
- 提示中不含手机号、验证码、数据库口令、SQL 结果或容器名
## 5. 未授权取码的实际结果
| 场景 | 实际结果 |
|---|---|
| `SMS_MOCK_ENABLED` 缺失 | 拒绝;输出 `test OTP retrieval is disabled`;**docker 未被调用** |
| `SMS_MOCK_ENABLED=false` | 拒绝;同上 |
| `ALLOW_TEST_OTP_DB_READ` 缺失 | 拒绝;同上 |
| `ALLOW_TEST_OTP_DB_READ=false` | 拒绝;同上 |
| 两者为 true 但无可用验证码 | `test OTP retrieval failed`,**不继续登录** |
| 两者为 true 但查询失败/结果非 6 位数字 | 同上,**不继续登录** |
## 6. F2 修改的全部文档与行
| 文件 | 行(整改后) | 修改前 | 修改后要点 |
|---|---|---|---|
| `docs/acceptance-week1.md` | 94 | `# Mock 登录(从日志获取验证码)` | 指向受控取码方式 |
| `docs/acceptance-week1.md` | 148 | 注: 验证码从应用日志中获取 | 明确日志不输出;列出双开关;生产禁止查表 |
| `docs/acceptance-week1.md` | 152 | 验证码打印到日志 | 明确不输出;标注明文存储遗留 |
| `docs/api-v0.1.md` | 27 | Mock环境验证码打印到日志 | 明确不输出 + 受控取得 |
| `docs/api-v0.1.md` | 174 | 验证码打印到日志 | 明确不输出;未接入供应商返回服务不可用 |
| `docs/frontend-integration.md` | 37-49(§3 整节重写) | 「Mock 验证码日志位置」+ 旧示例行 `MOCK SMS CODE: … code=******` + 「看终端输出/看日志文件/取最新一条」 | 改为「本地获取测试验证码(不通过日志)」:双开关 + 受控脚本 + 生产禁止查表 + 明文遗留风险 |
| `docs/frontend-integration.md` | 149 | 验证码打印在服务日志 | 改为不再输出 + 受控取得 |
| `docs/offer-api.md` | 262 | 验证码见应用日志 | 改为受控取得 + 生产禁止查表 |
| `docs/resume-api.md` | 189 | 登录学生账号(Mock 验证码,见日志) | 改为受控取得 + 生产禁止查表 |
| `docs/smoke-test.sh` | 全文头部与取码段 | 依赖日志取码 | 见第 1-5 节 |
**文档统一口径(已落实)**:
1. 删除所有「从控制台/日志查看验证码」的操作指导
2. 明确应用日志不会输出验证码
3. dev/test 本地自动化使用受控测试夹具/数据库内部只读查询
4. 冒烟脚本仅在 `mock=true` **且** `ALLOW_TEST_OTP_DB_READ=true` 时查表
5. 真实生产环境**禁止**查询验证码表辅助登录
6. `mock=false` 且无真实供应商时返回「服务不可用」
7. 明确当前尚未接入真实短信供应商
8. **未声称**验证码已加密/哈希存储
9. 明确验证码当前仍为数据库**明文**遗留风险
10. 示例中不含真实手机号、验证码、JWT 或数据库口令
**在途文件保护**:`docs/frontend-integration.md` 与 `docs/resume-api.md` 仅改动取码相关段落;`git diff` 复核确认简历上传加固内容(文件名净化、PDF/DOC/DOCX 结构校验、上传替换一致性、恶意软件扫描边界等)**逐字保留**,未覆盖、未还原、未重新格式化。
## 7. 全仓遗留搜索结果
```
grep -rn -i "MOCK SMS CODE|控制台.*验证码|验证码.*打印到日志|从日志获取|查看.*验证码"
docs README.md mydreamjob-server/src
```
- **「打印到日志 / 从日志获取」命中数:0**
- `MOCK SMS CODE` 在 `mydreamjob-server/src` 与 `docs/` 中的命中数:**0**
- 剩余匹配全部为**禁止性表述或无关标识符**(如「日志不得包含验证码」「不得写入日志」「SMS_CODE_INVALID」枚举名)
- `README.md:167` 本身即「不写入敏感信息」的正确约定,未修改
## 8. F3 使用的 Bean 装配方案
采用**推荐的单 `@Configuration` + 单 `@Bean` 方案**(不再依赖两套互斥 `@ConditionalOnProperty`):
```java
@Configuration
public class SmsSenderConfiguration {
private static final String MOCK_ENABLED_VALUE = "true";
@Bean
public SmsCodeSender smsCodeSender(
@Value("${app.sms.mock.enabled:false}") String rawEnabled) {
if (rawEnabled != null && MOCK_ENABLED_VALUE.equalsIgnoreCase(rawEnabled.trim())) {
return new MockSmsCodeSender();
}
return new UnavailableSmsCodeSender();
}
}
```
- `MockSmsCodeSender` / `UnavailableSmsCodeSender` 已移除 `@Component` 与 `@ConditionalOnProperty`,成为普通类
- 满足:始终恰好一个 Bean;仅显式 `true` 启用 Mock;非法值不启用 Mock;**不根据 profile 偷偷启用**;`application-dev.yml` 仍显式 `true`;`application.yml` 默认 `false`
- 不把选择逻辑放回 `SmsVerificationService`;不新增第二套发送流程;不用 try/catch Bean 缺失兜底;不在每次发送时读配置;**不输出配置原值到日志**
## 9. 各配置取值对应的实现
| `app.sms.mock.enabled` | 装配实现 |
|---|---|
| `true` / `TRUE` / `True` / `" true "` | `MockSmsCodeSender` |
| `false` | `UnavailableSmsCodeSender` |
| 缺失 | `UnavailableSmsCodeSender` |
| 空字符串(`=`) | `UnavailableSmsCodeSender` |
| 仅空白(`= `) | `UnavailableSmsCodeSender` |
| 非法值(`yes`/`1`/`on`/`enabled`/`null`/`mock`/`TRUE1`) | `UnavailableSmsCodeSender`(安全回退,**绝不启用 Mock**) |
## 10. 每种配置的 Bean 数量
由 `SmsSenderConfigurationTest.everySupportedConfigurationCreatesExactlyOneSenderBean` 断言(真实 `ApplicationContextRunner`,含 9 种取值 + 缺失属性):
**每种取值 `getBeanNamesForType(SmsCodeSender.class).length == 1`**。
## 11. 空值启动结果
| 层级 | 结果 |
|---|---|
| 快速装配测试(`ApplicationContextRunner`) | 上下文**启动成功**;恰好一个 Bean;类型 `UnavailableSmsCodeSender`;消费者构造注入成功;发送抛固定安全 `SERVICE_UNAVAILABLE` |
| **全应用上下文测试**(`SmsEmptyPropertyStartupIntegrationTest`,真实 H2 + Flyway + 全 Bean) | 应用上下文**启动成功**;恰好一个 `SmsCodeSender`;类型 `UnavailableSmsCodeSender`;`SmsVerificationService` **可注入**;发送返回固定安全服务不可用 ✅ |
整改前该取值导致 `APPLICATION FAILED TO START … required a bean of type 'com.mydreamjob.auth.sms.SmsCodeSender' that could not be found`(独立验收阶段已复现并记录在验收报告 F3)。
> 本轮按要求**未操作 Docker / 未执行真实 MySQL 黑盒**,故未做真实 JAR 启动;全应用上下文启动已由 H2+Flyway 覆盖,真实环境启动仍待独立复验。
## 12. 是否恢复验证码日志
**未恢复。** `grep -rn "MOCK SMS CODE" mydreamjob-server/src` 命中 0;`MockSmsCodeSender` 仅输出固定文本 `SMS verification code generated (mock sender)`;`SmsVerificationService` 仅两处 `log.warn`,参数为 `maskPhone(phone)`;`AuthService` 仅记录 `id`。
## 13. 原 S01 权限与事务回归结果
| 原 S01 要求 | 结果 |
|---|---|
| 不恢复验证码日志 | ✅(第 12 节) |
| 不输出完整手机号 | ✅ `SmsVerificationSecurityTest` 10/10 |
| Mock 发送器不打印验证码 | ✅ |
| mock=false 不返回虚假成功 | ✅ `SmsSenderConfigurationTest` + 回滚集成测试 |
| mock=false 发送失败事务回滚 | ✅ `SmsUnavailableSenderRollbackIntegrationTest` 3/3(真实 Spring + H2 + Flyway:失败后该手机号**无任何验证码行**、总数不变、无虚假成功审计) |
| 匿名 POST /jobs = 401 | ✅ `AuthenticationBoundaryIntegrationTest` 15/15 |
| STUDENT/ADMIN 创建岗位 = 403/2002 | ✅ |
| COMPANY 合法创建保持成功 | ✅ |
| 非法 principal 返回 401 | ✅ `CurrentUserAccessorTest` 11/11 |
| 不返回 NumberFormatException / 内部 principal | ✅ |
| OpenAPI 173/211 | ✅ |
| 无新增公网取码接口 | ✅(OpenAPI 173/211 不变) |
| 无新 Flyway/表/索引/ErrorCode | ✅ |
## 14. Shell 测试数量与结果
`docs/tests/smoke-test-sms-code-test.sh`(新增):**22 用例,PASS=22 / FAIL=0**
覆盖:mock=true+ALLOW=true 可取码、mock=false 拒绝、配置缺失拒绝、ALLOW 缺失/为 false 拒绝、无可用验证码安全失败、查询失败不继续、结果非数字/长度非法拒绝、查询为空时不继续登录(无 login 请求)、stdout/stderr 不含验证码、不含完整手机号、生成文件不含验证码、不 grep 应用日志、不存在 `MOCK SMS CODE`、REPLY/SMS/LOGIN_BODY 清理与 EXIT trap 存在、未授权时不触碰数据库、拒绝提示不含手机号、**授权模式下可完成登录流程(登录请求已发出且携带 6 位码、登录步骤判定 PASS)**。
测试通过 PATH 注入假 `docker`/`curl` 实现,**不要求 Docker 或真实 MySQL**,仓库内不写入真实验证码,结束时扫描输出确认标记未泄露。
## 15. Maven 专项、关联与全量测试数
| 命令 | Tests run |
|---|---|
| `-Dtest="SmsSenderConfigurationTest,SmsVerificationSecurityTest,SmsVerificationServiceTest,CurrentUserAccessorTest,AuthenticationBoundaryIntegrationTest"` | **56** |
| `-Dtest="*Auth*Test,*Security*Test,*Sms*Test,*Job*Test"` | **222** |
| `mvn test`(最终,含本轮新增 2 个集成测试) | **1671** |
| `mvn clean verify` | **1671** |
S01 相关类明细:
| 测试类 | Tests |
|---|---|
| `SmsSenderConfigurationTest`(F3,由 4 扩展至 12) | 12 |
| `SmsVerificationSecurityTest` | 10 |
| `SmsVerificationServiceTest` | 8 |
| `CurrentUserAccessorTest` | 11 |
| `AuthenticationBoundaryIntegrationTest` | 15 |
| `SmsUnavailableSenderRollbackIntegrationTest`(本轮新增) | 3 |
| `SmsEmptyPropertyStartupIntegrationTest`(本轮新增) | 2 |
## 16. Failures / Errors / Skipped
**Failures=0、Errors=0、Skipped=0**(全量与 clean verify 一致)
## 17. clean verify 结果
`mvn clean verify` → **BUILD SUCCESS**(4:34),产出 `target/mydreamjob-server-0.0.1-SNAPSHOT.jar`。
## 18. OpenAPI paths / operations
| 来源 | paths | operations |
|---|---|---|
| 静态 `docs/verify_openapi.py` | **173** | **211** |
| 校验输出 | `All checks passed!` | — |
未新增/删除路径或 operation;无新增测试取码端点。
## 19. 精确修改文件
**修改(已跟踪,本轮 F1/F2)**
| 文件 | 变更 |
|---|---|
| `docs/smoke-test.sh` | +176/−…(取码机制整体替换、双保护、脱敏输出、库模式钩子) |
| `docs/acceptance-week1.md` | 3 处取码表述 |
| `docs/api-v0.1.md` | 2 处取码表述 |
| `docs/frontend-integration.md` | §3 整节重写 + 1 处(**在途文件,最小改动**) |
| `docs/offer-api.md` | 1 处取码表述 |
| `docs/resume-api.md` | 1 处取码表述(**在途文件,最小改动**) |
**修改(S01 新建文件,本轮扩展,F3)**
| 文件 | 变更 |
|---|---|
| `auth/sms/MockSmsCodeSender.java` | 移除 `@Component`/`@ConditionalOnProperty`,改为普通类 |
| `auth/sms/UnavailableSmsCodeSender.java` | 同上 |
| `auth/SmsSenderConfigurationTest.java` | 由 4 用例扩展至 12(真实 ApplicationContextRunner + 消费者探针) |
**新增**
| 文件 | 用途 |
|---|---|
| `auth/sms/SmsSenderConfiguration.java` | F3 单 Bean 装配 |
| `auth/SmsUnavailableSenderRollbackIntegrationTest.java` | 真实 Spring+H2:失败不残留验证码 |
| `auth/SmsEmptyPropertyStartupIntegrationTest.java` | 空值全应用上下文启动 |
| `docs/tests/smoke-test-sms-code-test.sh` | F1 shell 安全测试(22 用例) |
## 20. Git 状态
- 分支:`fix/resume-upload-safety`(未变)
- **暂存区:空**
- `git diff --check`:无空白错误(仅 CRLF 提示)
- 迁移改动:**无**(V1–V41 共 41 个文件,最大版本 41)
- 删除文件:**0**
- 历史未跟踪文件:未删除、未移动、未覆盖(含 `docs/acceptance/s01-20260911/` 全部验收证据 43 个文件与验收报告,保持原样)
## 21. 是否修改 Flyway、表、ErrorCode 或公网接口
**均未修改。**
- Flyway:无新增/修改,V1–V41 原样
- 数据库表/索引:未新增或修改(验证码仍明文存储于 `sms_verification_codes`,属已记录遗留风险)
- `ErrorCode`:未被改动
- 公网接口:OpenAPI 保持 173 paths / 211 operations;未新增取码接口
## 22. 明确未执行 git add -A / commit / push
**未执行** `git add -A`、**未 commit**、**未 push**(亦未 `reset`/`restore`/`checkout`/`clean`)。
## 23. 明确未操作 Docker、未执行真实 MySQL 黑盒
**未执行任何 Docker 命令**、**未执行真实 MySQL 黑盒**。本轮全部验证均为:shell 测试(假 docker/curl 注入)+ Maven 单元/集成测试(H2 + Flyway + 真实 Spring 上下文)。
## 24. 真实 MySQL / Docker 复验仍待独立验收
F1 的数据库取码路径(`docker exec` + 容器内 SQL)**尚未在真实 MySQL 环境端到端执行**;F3 的运行期 JAR 启动(含空值 / 缺失 / 非法值)亦未在真实环境启动验证。二者**必须由独立复验在真实 MySQL/Docker 环境完成**,不得以本轮 H2 结果代替。
---
## 遗留与后续
1. **验证码仍为数据库明文存储**(本轮未改表结构,属已知遗留)
2. **真实短信供应商未接入**;生产 `mock=false` 下发码一律安全失败,需产品确认上线策略
3. **存量 43 处直接身份解析**未收口(均不可匿名到达;`DeletionGuardInterceptor` 对 20 位数字溢出仍可能 500)——非本轮范围
4. **安全层 401 响应不含 requestId**(既有 `AuthenticationEntryPoint` 固定载荷),与业务层错误响应格式不一致
5. 上一轮验收的其余观察项(失败后残留 `sms:rate` 限流键 60s;Spring Boot 默认 `generated security password` 入日志)**本轮未处理**,仍属非阻断遗留
---
# 第三轮定向整改(F4 / F5)
- **整改日期**:2026-09-11(第二轮之后)
- **独立复验结论**:S01 仍 FAIL —— F2/F3 已通过,但新发现 F4(xtrace 泄露)与 F5(数据库密码进入 mysql argv)两个阻断项
- **本轮范围**:仅修 F4/F5,并相应扩展 shell 测试、修正文档测试口径
- **本轮修改文件**:`docs/smoke-test.sh`、`docs/tests/smoke-test-sms-code-test.sh`、本报告
- **本轮未触碰**:S01 Java 生产代码(时间戳仍为 15:45,本轮为 17:38+ 仅改 docs)
## T1. F4 修复前的泄露位置
以 `bash -x` 执行时,以下位置被 trace 到 stderr(原文来自复验报告,此处仅列位置):
| 位置 | 泄露内容 | 原因 |
|---|---|---|
| `PHONE="${STUDENT_PHONE}"` / 生成分支 | 完整手机号 | 脚本开头未关闭 xtrace |
| `SMS="$REPLY"` | 验证码 | `sms_code()` 返回前 `set -x` 恢复,赋值被 trace |
| `LOGIN_BODY="{...code...}"` | 验证码 + 完整手机号 | 赋值被 trace |
| `printf '%s' "$LOGIN_BODY" \| curl ...` | 登录请求体(含验证码) | 命令与参数被 trace |
| `LOGIN="$(...)"` | 登录响应全文(含 JWT) | 赋值被 trace |
| `TOKEN="$(extract_token "$LOGIN")"` | JWT | 赋值被 trace |
| `AUTH="Authorization: Bearer $TOKEN"` | JWT | 赋值被 trace |
| 后续 `curl ... -H "$AUTH"` | JWT | 命令参数被 trace |
| `sms_code()` 内部 `set +x` / `set -x` | 只保护了取码阶段 | 保护范围不足 |
## T2. 全局 xtrace 处理方式
采用「**开头立即关闭且全程不恢复**」(任务书推荐方案):
```bash
#!/usr/bin/env bash
# ── xtrace 防护(S01 第三轮 F4)──
# 必须在读取任何环境变量、组装任何请求之前关闭 xtrace。
# 脚本是独立可执行程序,安全优先:全程不重新 set -x。
case "$-" in
*x*) set +x ;;
esac
set -u
```
- 位置:`set +x` 位于**任何环境变量读取与任何请求组装之前**(仅在 shebang 与注释之后)
- `sms_code()` 内部的 `xtrace_was_on` 与恢复 `set -x` 逻辑**已彻底删除**(`grep -c xtrace_was_on` = 0)
- 脚本中**不存在可执行的 `set -x`**(仅 1 处注释提及,测试 F4-9 已断言)
- 保护范围覆盖全脚本:验证码取码、手机号、请求体、登录响应、TOKEN、AUTH 及**后续所有携带 Authorization 的 curl 调用**
- 未使用 `curl -v`/`--trace`/`--trace-ascii`(测试第 20 条断言)
- 登录响应全文不再交给打印函数:新增 `extract_status_fields()` + `report_fields()`,登录步骤只把 http / business code / requestId 传出去(测试第 21 条断言)
## T3. `bash -x` 真实执行结果
由测试脚本内部驱动(捕获被测脚本 stdout+stderr 后扫描标记,只输出 PASS/FAIL):
| 用例 | 结果 |
|---|---|
| F4-1 bash -x 下不泄露验证码 | PASS |
| F4-2 bash -x 下不泄露完整手机号 | PASS |
| F4-3 bash -x 下不泄露 JWT | PASS |
| F4-4 bash -x 下不泄露 Authorization 头 | PASS |
| F4-5 bash -x 下不泄露数据库密码 | PASS |
| F4-6 bash -x 下不泄露 LOGIN_BODY | PASS |
| F4-7 bash -x 下正常流程仍可完成登录 | PASS |
| F4-8 bash -x 下登录步骤判定 PASS | PASS |
| F4-9 脚本中不存在可执行的 set -x | PASS |
| F4-10 失败流程(无可用验证码)同样不泄露 | PASS |
| F4-11 source 模式下 REPLY 契约可用 | PASS |
| F4-12 source 模式下清理后无敏感变量残留 | PASS |
| F4-13 source 模式下 EXIT trap 已登记清理函数 | PASS |
| F4-14 cleanup_secrets 覆盖 PHONE/TOKEN/AUTH/COMPANY_TOKEN | PASS |
另外单独执行 `bash -x docs/tests/smoke-test-sms-code-test.sh` → exit=0、PASS=51/FAIL=0;
该模式下测试脚本自身的假值可能被 trace(任务书已明确属预期,不作为安全判定),
但**测试结论输出(stdout)中标记值命中数为 0**。
## T4. stdout/stderr 敏感标记命中数
以 `bash -x` 执行被测 `smoke-test.sh`(正常流程与失败流程各一次),对捕获的 stdout+stderr 递归扫描:
| 标记类别 | 命中数 |
|---|---|
| 测试验证码 | **0** |
| 完整手机号 | **0** |
| 测试 JWT | **0** |
| `Authorization: Bearer` | **0** |
| 数据库密码标记 | **0** |
| `LOGIN_BODY=` | **0** |
(报告不记录标记值本身;标记值均为测试用固定假值,仅存在于测试脚本内。)
## T5. TOKEN / AUTH 生命周期
| 变量 | 生命周期 |
|---|---|
| `REPLY` | `sms_code()` 成功时写入;调用方立即 `SMS="$REPLY"; unset REPLY` |
| `SMS` | 组装 `LOGIN_BODY` 后立即 `unset SMS` |
| `LOGIN_BODY` | 经 `--data-binary @-` 送入 curl 后立即 `unset LOGIN_BODY` |
| `LOGIN`(响应全文) | 仅用于提取 http/code/requestId 与 token;`extract_token` 后立即 `unset LOGIN` |
| `LOGIN_FIELDS` | 只含可安全打印的三字段,用完 `unset` |
| `TOKEN` | **保留到流程结束**(后续接口需要),但不打印、不写文件、不进验收产物 |
| `AUTH` | 由 TOKEN 派生,仅作为 curl 头参数使用;EXIT trap 统一清理 |
| `COMPANY_TOKEN` | 调用方传入,仅作 curl 头参数;EXIT trap 统一清理 |
| `PHONE` / `STUDENT_PHONE` | EXIT trap 统一清理;控制台只输出脱敏形式 |
`cleanup_secrets()` 现覆盖:`PHONE SMS OTP REPLY LOGIN_BODY LOGIN TOKEN AUTH` 与 `STUDENT_PHONE COMPANY_TOKEN`,由 `trap cleanup_secrets EXIT` 统一清理。
注意:登记块**不再**给 `PHONE`/`STUDENT_PHONE`/`COMPANY_TOKEN` 赋空值(避免覆盖调用方传入值)。
## T6. F5 修复前后的 mysql 调用形式
**修复前**
```bash
docker exec -i "$container" sh -c \
'mysql -uroot -p"$MYSQL_ROOT_PASSWORD" -N -B "${MYSQL_DATABASE:-mydreamjob}"'
```
容器内 shell 会把 `$MYSQL_ROOT_PASSWORD` 展开为 `-p<真实密码>`,进入 mysql 进程 argv。
**修复后**
```bash
docker exec -i "$container" sh -c '
if [ -z "${MYSQL_USER:-}" ] || [ -z "${MYSQL_PASSWORD:-}" ]; then
echo "db credentials unavailable" >&2
exit 3
fi
MYSQL_PWD="$MYSQL_PASSWORD" exec mysql \
-u"$MYSQL_USER" \
-N -B \
"${MYSQL_DATABASE:-mydreamjob}"' 2>/dev/null \
| head -n 1
```
## T7. mysql argv 是否含密码
测试用假 `mysql` 记录自身真实 argv(它正是被检查的进程),实测结果形态为:
```
-u<应用账号> -N -B <数据库名>
```
| 断言 | 结果 |
|---|---|
| F5-1 mysql argv 不含数据库密码值 | PASS |
| F5-2 宿主机 docker 命令行不含密码值 | PASS |
| F5-3 容器侧脚本文本不含密码值 | PASS |
| F5-4 容器侧脚本以变量名引用 MYSQL_PASSWORD | PASS |
| F5-5 mysql argv 不含 `-p<密码>` | PASS |
| F5-6 mysql argv 不含 `--password` | PASS |
| F5-13 密码不经 argv 传递 | PASS |
| F5-14 密码经 `MYSQL_PWD` 环境变量传递 | PASS |
## T8. 使用的数据库账号类型
- 使用**应用账号**(容器环境变量 `MYSQL_USER` / `MYSQL_PASSWORD` / `MYSQL_DATABASE`)
- **不使用 root**;`MYSQL_ROOT_PASSWORD` 在脚本中命中数为 0,`-uroot` 命中数为 0(F5-7 / F5-8 PASS)
- 凭据缺失时 `exit 3` 安全失败,**不回退 root**(F5-15 PASS)
- 未读取、未打印、未落盘凭据;未修改 compose.yaml;未修改 MySQL 用户权限;查询仅 `SELECT`(F5-11/F5-12 PASS)
## T9. Shell 测试实际数量
`docs/tests/smoke-test-sms-code-test.sh`:**51 用例,PASS=51 / FAIL=0**
构成:F1 取码保护 10 项 + F4 xtrace/源模式 14 项 + F5 argv 18 项 + 静态保证与文档门禁 9 项。
## T10. Maven 专项 / 关联 / 全量数量
| 命令 | Tests run |
|---|---|
| 7 类安全专项(含空值与回滚集成测试) | **61** |
| `*Auth*Test,*Security*Test,*Sms*Test,*Job*Test` | **227** |
| `mvn test` | **1671** |
| `mvn clean verify` | **1671** |
## T11. Failures / Errors / Skipped
**Failures=0、Errors=0、Skipped=0**(专项、关联、全量、clean verify 全部一致)
## T12. clean verify
`mvn clean verify` → **BUILD SUCCESS**(4:34),产出最新 JAR。
## T13. OpenAPI 数量
**173 paths / 211 operations**(`All checks passed!`);本轮未改 Controller 映射、未新增取码接口。
## T14. 精确修改文件(本轮)
| 文件 | 变更 |
|---|---|
| `docs/smoke-test.sh` | 开头新增全局 xtrace 关闭(全程不恢复);删除 `xtrace_was_on`/恢复逻辑;`otp_db_query` 改为应用账号 + `MYSQL_PWD`(去 root、去 `-p`);`cleanup_secrets` 覆盖面扩展;新增 `report_fields`/`extract_status_fields`,登录响应不再交给打印函数 |
| `docs/tests/smoke-test-sms-code-test.sh` | 扩展至 51 用例:真实 `bash -x` 执行与标记扫描、假 docker 执行容器侧脚本 + 假 mysql 记录 argv、source 模式泄漏防护、活跃文件文档门禁 |
| `docs/s01-remediation-f1f2f3-20260911.md` | 新增本「第三轮定向整改」章节 |
**未修改**:任何 Java 生产代码、任何既有测试、`docs/smoke-test.sh` 以外的文档正文、`.env`、compose.yaml、Flyway。
## T15. Git 状态
- 分支:`fix/resume-upload-safety`(未变)
- 暂存区:**空**
- `git diff --check`:无空白错误(仅 CRLF 提示)
- 迁移改动:**无**(V1–V41 共 41 个)
- 删除文件:**0**;历史未跟踪文件:**145 个全部保留**
- 本轮已跟踪文件改动仅 `docs/smoke-test.sh`(S01 Java 文件时间戳仍为 15:45,本轮未触碰)
## T16. 是否修改 Flyway、表、ErrorCode 或接口
**均未修改**:无新增/修改迁移、无表/索引变更、`ErrorCode` 未改、OpenAPI 保持 173/211。
## T17. 明确未暂存、未 commit、未 push
**未执行** `git add` / `git add -A`、**未 commit**、**未 push**(亦未 `reset`/`restore`/`checkout`/`clean`)。
## T18. 真实 MySQL / Docker 最终复验仍待独立执行
本轮**未执行任何 Docker 命令**,**未连接真实 MySQL**:
- F5 的 `docker exec` + `MYSQL_PWD` 路径仅在**假 docker / 假 mysql** 下验证了 argv 与 stdin 形态;
真实容器中 `MYSQL_USER` / `MYSQL_PASSWORD` 是否存在、`MYSQL_PWD` 是否被 mysql 客户端接受,**必须由独立复验在真实 Docker/MySQL 环境确认**。
- F4 的 xtrace 防护已由真实 `bash -x` 本地执行验证(无需容器),但**完整真实登录链路**仍待真实环境复验。
## T19. 文档测试口径修正(§7)
历史验收报告 `docs/s01-independent-mysql-acceptance-20260911.md` 与本报告**合理保留修复前缺陷证据**(含修复前的日志依赖描述与旧命令),因此**不得**以「整个 docs 目录命中数为 0」作为门禁结论。
修正后的口径(已落入 shell 测试第 22-24 条断言):
- **A. 活跃操作文档与正式代码**(必须无「从日志取码」有效指导):
`docs/acceptance-week1.md`、`docs/api-v0.1.md`、`docs/frontend-integration.md`、`docs/offer-api.md`、`docs/resume-api.md`、`docs/smoke-test.sh`、`README.md`、`mydreamjob-server/src/main/java`
- **B. 历史验收报告**(允许保留修复前失败证据,须明示为历史):
`docs/s01-independent-mysql-acceptance-20260911.md`、`docs/s01-remediation-f1f2f3-20260911.md`
测试使用**精确活跃文件列表**扫描(不扫描全部 docs),并额外断言两份历史报告仍然存在。
## T20. 本轮遗留(非阻断,待后续)
1. JWT 仍作为 `-H "Authorization: Bearer <token>"` 的 curl 参数存在于**进程 argv**(非 xtrace/stdout/stderr 问题;如需消除,应改用 `curl --config` 经 stdin 传头)
2. 验证码仍为数据库明文存储(未改表结构)
3. 真实短信供应商未接入
4. 存量 43 处直接身份解析未收口;安全层 401 响应不含 requestId
5. 上轮观察项(失败后残留 `sms:rate` 限流键 60s;Spring Boot 默认 `generated security password` 入日志)本轮未处理
---
**S01第三轮定向整改完成,待独立代码复验及真实MySQL/Docker黑盒复验。**
---
# 第四轮定向整改(F6)
- **整改日期**:2026-09-14
- **独立复验结论**:S01 仍 FAIL —— F4 与 F5 已通过(bash -x 不泄露 / Shell 51 项 / Maven 安全专项 61 项 / 密码不进 mysql argv 均通过),新阻断项 **F6**:冒烟脚本使用了 `sms_verification_codes` 不存在的列 `expires_at`
- **本轮范围**:仅修 F6,并补真实表结构契约测试
- **本轮修改文件**:`docs/smoke-test.sh`、`docs/tests/smoke-test-sms-code-test.sh`、`docs/tests/smoke-test-schema-contract-test.sh`(新增)、本报告
- **本轮未触碰**:全部 Java 生产代码、实体、Repository、Flyway、数据库表、`ErrorCode`、公网接口
## G1. F6 错误位置
| 项 | 内容 |
|---|---|
| 文件 | `docs/smoke-test.sh` |
| 行 | **L122** |
| 修复前 | `" AND expires_at > NOW()" \` |
| 修复后 | `" AND expired_at > NOW()" \` |
| 后果 | 真实 MySQL 报 ERROR 1054,冒烟脚本取不到验证码,**F1 未闭环** |
## G2. 真实 MySQL SHOW COLUMNS 结果摘要
独立复验在真实 MySQL 执行 `SHOW COLUMNS FROM sms_verification_codes`,实际字段为:
```
id phone code purpose attempts max_attempts expired_at used created_at
```
**不存在 `expires_at`。** 该结论与两个契约来源一致:
| 来源 | 位置 | 结果 |
|---|---|---|
| 实体 `@Column` | `auth/SmsVerificationCode.java:46` | `@Column(name = "expired_at", nullable = false)` |
| 建表 Flyway | `db/migration/V1__base_schema.sql:37` | `expired_at DATETIME NOT NULL` |
## G3. 修复前的真实报错
```
ERROR 1054 (42S22): Unknown column 'expires_at' in 'where clause'
```
## G4. 修复后的 SQL
```sql
SELECT code FROM sms_verification_codes
WHERE phone = '<phone>' AND purpose = '<purpose>' AND used = 0
AND expired_at > NOW()
ORDER BY created_at DESC, id DESC LIMIT 1;
```
除列名外**未改动任何查询口径**,以下均保持不变:phone 精确匹配、purpose 精确匹配、`used = 0`、`expired_at > NOW()`、`ORDER BY created_at DESC, id DESC`、`LIMIT 1`、仅 SELECT、使用应用数据库账号(非 root)、密码经 `MYSQL_PWD` 不进 argv、SQL 经 stdin、验证码不输出。
## G5. 新增的 Shell 断言
`docs/tests/smoke-test-sms-code-test.sh` 由 **51 项扩展至 69 项**,新增 Part 6 共 **18 条**:
| 编号 | 断言 |
|---|---|
| F6-1 | `otp_db_query` 使用 `expired_at > NOW()` |
| F6-2 | `otp_db_query` 不再出现错误字段 `expires_at` |
| F6-3 | 查询只使用 `sms_verification_codes` 的真实列 |
| F6-4 | 真实列清单由 Flyway 派生且含 `expired_at`(非空、非自证) |
| F6-5 | 假 mysql 捕获到完整 stdin SQL(非无条件返回验证码) |
| F6-6 | SQL 字段错误时假 mysql 返回失败(非 0) |
| F6-6b | 字段错误时报 ERROR 1054 (42S22) |
| F6-7 | 字段错误时 `sms_code()` 不继续登录 |
| F6-7b | 字段错误时未发出任何 login 请求 |
| F6-8 | 正确字段时取码成功并完成登录 |
| F6-9 | `expired_at` 恰好等于 NOW() 时不得取出 |
| F6-10 | `expired_at` 晚于 NOW() 时可以取出 |
| F6-11 | `expired_at` 早于 NOW() 时不得取出 |
| F6-12 | `used=1` 不得取出 |
| F6-13 | `purpose` 不匹配不得取出 |
| F6-14 | `phone` 不匹配不得取出 |
| F6-15 | 多条有效记录只取 `created_at`/`id` 最新一条 |
| F6-15b | 与输入顺序无关,按 `created_at`/`id` 排序取值 |
**另新增独立契约门禁** `docs/tests/smoke-test-schema-contract-test.sh`(**15 条**),契约来源为**实体 `@Column` + 建表 Flyway**,并交叉校验二者列集合一致:
C1–C4 来源存在性与解析非空 → C5 两个独立来源列集合一致 → C6 `expired_at` 存在 → C7 **`expires_at` 不存在**(表限定)→ C8 实体映射存在 → C9 `expires_at` 确实是其他表的真实列 → C10–C15 脚本 SQL 的表名、列存在性、列不含 `expires_at`、所用列全部真实。
## G6. fake docker / fake mysql 是否校验 SQL
**本轮之前:不校验。** 旧假 mysql 只是 `cat` 掉 stdin 后无条件 `printf "${FAKE_CODE}"`,因此错误的 `expires_at` 也能"取到"验证码并通过测试——这正是 F6 能逃过 51 项测试的原因。
**本轮之后:校验。** 假 mysql 改为:
1. 完整读取并保存 stdin SQL(不再丢弃);
2. 若 SQL 命中 `sms_verification_codes` 且包含 `expires_at` → 如实复现真实 MySQL 的 `ERROR 1054 (42S22)` 并 `exit 1`;
3. 设置 `FAKE_ROWS` 时,按行套用 `WHERE phone/purpose/used` 与 `expired_at > NOW()` 语义、按 `created_at DESC, id DESC` 取值;未设置时保留历史行为供 F1/F5 既有用例使用。
## G7. 过期 / 有效 / used / purpose / phone 口径
| 场景 | 假表语义 | 期望 | 结果 |
|---|---|---|---|
| `expired_at` 恰好 = NOW() | offset = 0 | 不取出(查询为严格 `>`) | PASS |
| `expired_at` 晚于 NOW() | offset = +300 | 取出 | PASS |
| `expired_at` 早于 NOW() | offset = −300 | 不取出 | PASS |
| `used = 1` | — | 不取出 | PASS |
| `purpose` 不匹配 | 行 purpose=`register`,查 `login` | 不取出 | PASS |
| `phone` 不匹配 | 行 phone 为另一号码 | 不取出 | PASS |
| 多条有效 | 两条均有效,`created_at` 不同 | 只取最新一条 | PASS |
## G8. F4 / F5 回归结果
全部保持通过(本轮未改动 xtrace 防护与 `MYSQL_PWD` 路径):
| 回归项 | 结果 |
|---|---|
| `bash -x` 不泄露验证码 / 完整手机号 / JWT / Authorization / 数据库密码 / LOGIN_BODY | PASS(F4-1 ~ F4-6) |
| `bash -x` 下正常流程仍可完成登录 | PASS(F4-7、F4-8) |
| 脚本中不存在可执行的 `set -x` | PASS(F4-9) |
| 失败流程同样不泄露 | PASS(F4-10) |
| source 模式 REPLY 契约 / 无敏感变量残留 / EXIT trap | PASS(F4-11 ~ F4-14) |
| mysql argv 不含密码值、不含 `-p`/`--password` | PASS(F5-1 ~ F5-6) |
| 不使用 root / 使用应用账号与数据库名 | PASS(F5-7 ~ F5-10) |
| SQL 经 stdin 且仅 SELECT | PASS(F5-11、F5-12) |
| 密码经 `MYSQL_PWD` 传递、不经 argv | PASS(F5-13、F5-14) |
| 凭据缺失时安全失败(不回退 root) | PASS(F5-15) |
| 失败路径 stderr 不含数据库密码 | PASS(F5-18) |
| 不恢复 `MOCK SMS CODE` 日志、活跃文档不指导从日志取码 | PASS(14、22、23) |
## G9. Shell 测试数量
| 文件 | 用例数 | 结果 |
|---|---|---|
| `docs/tests/smoke-test-sms-code-test.sh` | **69** | PASS=69 / FAIL=0 |
| `docs/tests/smoke-test-schema-contract-test.sh` | **15** | PASS=15 / FAIL=0 |
合计 **84** 条,全部通过(真实 Git Bash 执行)。
**断言非空转的证据(测试自检)**:把 `smoke-test.sh` 还原为 buggy 版本(`expires_at`)放入镜像目录,用**同一套测试**执行:
- `smoke-test-sms-code-test.sh` → `PASS=56 FAIL=13`,其中 **F6-1 / F6-2 / F6-3 翻红**,F6-3 直接点名不存在的列 `expires_at`;F6-8/10/15/15b 因取不到码一并失败;
- `smoke-test-schema-contract-test.sh` → `PASS=12 FAIL=3`,**C12 / C13 / C14 翻红**,C14 点名 `expires_at`。
即:本轮断言确实能识别**不存在的 SQL 列**,而非"改前改后都通过"。
## G10. Maven 专项 / 关联 / 全量测试数
| 命令 | Tests run |
|---|---|
| `-Dtest="SmsSenderConfigurationTest,SmsVerificationSecurityTest,SmsVerificationServiceTest,CurrentUserAccessorTest,AuthenticationBoundaryIntegrationTest,SmsEmptyPropertyStartupIntegrationTest,SmsUnavailableSenderRollbackIntegrationTest"` | **61** |
| `-Dtest="*Auth*Test,*Security*Test,*Sms*Test,*Job*Test"` | **227** |
| `mvn test` | **1671** |
| `mvn clean verify` | **1671** |
本轮仅改 `docs/` 下的脚本与报告,未增删任何 Java 测试,故总数与上一轮一致(1671)。
## G11. Failures / Errors / Skipped
**Failures=0、Errors=0、Skipped=0**(专项、关联、全量、clean verify 四者一致)。
## G12. clean verify
`mvn clean verify` → **BUILD SUCCESS**(Total time 02:11),产出 `target/mydreamjob-server-0.0.1-SNAPSHOT.jar`。
## G13. OpenAPI 计数
`python docs/verify_openapi.py` → **All checks passed!**,**173 paths / 211 operations**(与上一轮一致,未新增/删除路径或 operation)。
## G14. Git 状态
- 分支:`fix/resume-upload-safety`(未变)
- **暂存区:空**
- `git diff --check`:无空白错误(仅 CRLF 提示)
- 迁移改动:**无**(`git diff --name-only -- mydreamjob-server/src/main/resources/db/migration` 无输出;目录内 41 个文件,最大版本 `V41__message_recall.sql`)
- 本轮文件时间戳:`docs/smoke-test.sh` 09:22、`smoke-test-sms-code-test.sh` 09:24、`smoke-test-schema-contract-test.sh` 09:27、本报告 09:32(均 2026-09-14)
- **S01 Java 生产代码时间戳仍为 2026-09-11 15:45**,本轮未触碰
- 删除文件:**0**;历史未跟踪文件:未删除、未移动、未覆盖
## G15. 明确未修改 Java、Flyway、表、ErrorCode 或接口
**均未修改。**
- Java 生产代码:**0 处改动**(时间戳可证)
- 实体与 Repository:未改
- Flyway:未新增/修改,V1–V41 原样
- 数据库表/列/索引:未新增或修改;**未为迁就脚本把列改成 `expires_at`**
- `ErrorCode`:未改
- 公网接口:OpenAPI 保持 173/211,未新增取码接口
- **未恢复验证码日志**(`grep -rn "MOCK SMS CODE" mydreamjob-server/src` 命中 0)
## G16. 明确未暂存、commit、push
**未执行** `git add` / `git add -A`、**未 commit**、**未 push**(亦未 `reset`/`restore`/`checkout`/`clean`)。**未操作 Docker**,未进入 S02。
## G17. 真实 MySQL 完整冒烟链仍待最终独立复验
本轮**未执行任何 Docker 命令、未连接真实 MySQL**。全部验证均为:shell 测试(假 docker / 假 mysql 注入)+ Maven H2/Flyway 测试。
因此以下**必须由独立复验在真实 MySQL/Docker 环境完成**:
1. `SHOW COLUMNS FROM sms_verification_codes` 的 Field 集合与 Flyway 列集合对账(含 `expired_at`、不含 `expires_at`);
2. 修复后的取码 SQL 在真实 MySQL 端到端执行成功(不再出现 ERROR 1054);
3. F1 完整冒烟链(发码 → 取码 → 登录 → 后续接口)闭环。
**不得以本轮假库结果代替真实环境验收。**
## G18. 一处必须记录的旁证(不在本轮修改范围)
`docs/acceptance/s01-20260911/snapshot-sms.sql:4` 同样使用 `expires_at` 查询 `sms_verification_codes`:
```sql
SELECT id, purpose, CHAR_LENGTH(code) AS code_len,
TIMESTAMPDIFF(SECOND, NOW(), expires_at) AS expire_delta_s, ...
FROM sms_verification_codes
```
这是同一字段错误的传播。该文件属上一轮独立验收证据,**不在本轮允许修改的文件范围内**,故保持原样未改,仅在此记录,供后续处理。
## G19. 字段名易混淆的说明(供后续维护)
`expires_at` 并非拼写错误,它是本仓库中**三张表的真实列**:
| 表 | 迁移 |
|---|---|
| `personal_data_export_tasks.expires_at` | V30 |
| `ability_attempts.expires_at` | V35 |
| `talent_invitations.expires_at`(含索引) | V40 |
只有 `sms_verification_codes` 用 `expired_at`。因此本轮门禁**按表限定**,未写成"全库禁用 `expires_at`"——那样会误报并掩盖真实错误。契约门禁 C9 专门守卫这一区分。
---
**S01第四轮定向整改完成,待最终独立代码复验及真实MySQL/Docker黑盒复验。**

225
docs/smoke-test.sh

@ -1,20 +1,56 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# ── xtrace 防护(S01 第三轮 F4)───────────────────────────────
# 必须在读取任何环境变量、组装任何请求之前关闭 xtrace。
# 脚本是独立可执行程序,安全优先:全程不重新 set -x,
# 避免验证码 / 完整手机号 / LOGIN_BODY / 登录响应 / TOKEN / AUTH 进入 stderr。
case "$-" in
*x*) set +x ;;
esac
set -u set -u
# MyDreamJob backend RC smoke test. # MyDreamJob backend RC smoke test.
# Required tools: curl, grep, sed, head, tail. # Required tools: curl, grep, sed, head, tail, docker(仅取测试验证码时)。
# Environment: # Environment:
# BASE_URL API base URL (default: http://127.0.0.1:8080/api/v1) # BASE_URL API base URL (default: http://127.0.0.1:8080/api/v1)
# STUDENT_PHONE Fresh 11-digit phone; generated when omitted # STUDENT_PHONE Fresh 11-digit phone; generated when omitted
# COMPANY_TOKEN Company JWT; company status step is SKIP when omitted # COMPANY_TOKEN Company JWT; company status step is SKIP when omitted
# SERVER_LOG Server log path used to read Mock SMS codes # SMS_MOCK_ENABLED=true **必需**:声明被测应用以 app.sms.mock.enabled=true 运行
# ALLOW_TEST_OTP_DB_READ=true **必需**:显式授权本次本地测试从数据库读取验证码
#
# 安全说明(S01 定向整改 F1 / 第三轮 F4+F5):
# * 应用日志**不再**输出验证码,本脚本也**不得**从日志/控制台/stdout 读取验证码。
# * 仅在 [SMS_MOCK_ENABLED=true] **且** [ALLOW_TEST_OTP_DB_READ=true] 时,
# 才通过容器内部只读 SQL 取得验证码;任一条件不满足立即安全失败。
# * 验证码只保存在 shell 局部变量 / REPLY 约定中,不写日志、不写文件、不输出到终端。
# * **xtrace 永远关闭**:即使以 `bash -x` 调用,脚本开头即 set +x 且全程不恢复。
# * 数据库凭据通过容器进程环境 MYSQL_PWD 传递,**不进入 mysql 命令行参数**;
# 使用应用账号 MYSQL_USER(不使用 root,缺失即安全失败)。
# * 不接入真实短信供应商;生产环境禁止使用该取码方式。
BASE_URL="${BASE_URL:-http://127.0.0.1:8080/api/v1}" BASE_URL="${BASE_URL:-http://127.0.0.1:8080/api/v1}"
SERVER_LOG="${SERVER_LOG:-}"
PASS=0 PASS=0
FAIL=0 FAIL=0
SKIP=0 SKIP=0
# ── 凭据变量登记(退出时统一清理)─────────────────────────────
# 覆盖全部敏感变量:手机号、验证码、登录请求体、登录响应、JWT、Authorization 头。
# 注意:不得在此处给 PHONE / STUDENT_PHONE / COMPANY_TOKEN 赋空值,
# 它们携带调用方传入值或由后续逻辑生成。
SMS=""
OTP=""
REPLY=""
LOGIN_BODY=""
LOGIN=""
TOKEN=""
AUTH=""
cleanup_secrets() {
unset PHONE SMS OTP REPLY LOGIN_BODY LOGIN TOKEN AUTH 2>/dev/null || true
unset STUDENT_PHONE COMPANY_TOKEN 2>/dev/null || true
}
trap cleanup_secrets EXIT
if [ -n "${STUDENT_PHONE:-}" ]; then if [ -n "${STUDENT_PHONE:-}" ]; then
PHONE="$STUDENT_PHONE" PHONE="$STUDENT_PHONE"
else else
@ -27,20 +63,113 @@ ok() { echo "[PASS] $1"; PASS=$((PASS + 1)); }
fail() { echo "[FAIL] $1"; FAIL=$((FAIL + 1)); } fail() { echo "[FAIL] $1"; FAIL=$((FAIL + 1)); }
skip() { echo "[SKIP] $1"; SKIP=$((SKIP + 1)); } skip() { echo "[SKIP] $1"; SKIP=$((SKIP + 1)); }
resolve_log() { # 手机号脱敏(控制台输出不展示完整号码)
case "$SERVER_LOG" in mask_phone() {
[A-Za-z]:/*) local p="${1:-}"
local drive rest if [ "${#p}" -lt 7 ]; then
drive="$(printf '%s' "${SERVER_LOG:0:1}" | tr '[:upper:]' '[:lower:]')" printf '%s' '***'
rest="${SERVER_LOG:2}" return
if [ -d /mnt/c ]; then fi
echo "/mnt/$drive$rest" printf '%s****%s' "${p:0:3}" "${p: -4}"
else }
echo "/$drive$rest"
fi # ── 布尔解析(大小写/空白不敏感,仅 true 为真)────────────────
;; is_true() {
*) echo "$SERVER_LOG" ;; local v
v="$(printf '%s' "${1:-}" | tr -d '[:space:]' | tr '[:upper:]' '[:lower:]')"
[ "$v" = "true" ]
}
# ── 取码双重保护 ─────────────────────────────────────────────
# 保护 1:被测应用必须显式处于 mock 短信模式
require_mock_sms_mode() {
is_true "${SMS_MOCK_ENABLED:-}"
}
# 保护 2:本次执行必须显式授权读取验证码表
require_test_otp_db_read_permission() {
is_true "${ALLOW_TEST_OTP_DB_READ:-}"
}
# 从项目实际 compose 文件解析 mysql 容器名(不硬编码、不假设)
# 使用 BASH_SOURCE 以便脚本被 `source` 时仍能正确定位自身目录
compose_mysql_container() {
local self base compose
self="${BASH_SOURCE[0]:-$0}"
base="$(cd "$(dirname "$self")" && pwd)"
for compose in "$base/../backend-infra/compose.yaml" "$base/../backend-infra/docker-compose.yml"; do
[ -f "$compose" ] || continue
awk '
/^[[:space:]]+mysql:[[:space:]]*$/ { in_mysql=1; next }
/^[[:space:]]+[A-Za-z0-9_-]+:[[:space:]]*$/ { in_mysql=0 }
in_mysql && /^[[:space:]]+container_name:[[:space:]]*/ {
sub(/^[[:space:]]*container_name:[[:space:]]*/, ""); print; exit
}
' "$compose"
done
}
# 容器内只读查询(F5:凭据经进程环境 MYSQL_PWD 传递,不进入 mysql argv;SQL 经 stdin)
# - 使用应用账号 MYSQL_USER(不使用 root;缺失即安全失败,不回退 root)
# - 仅允许 SELECT;查询失败返回非 0,由 sms_code() 输出固定安全错误
otp_db_query() {
local phone="$1" purpose="$2" container
container="$(compose_mysql_container | head -n 1)"
[ -n "$container" ] || return 1
printf '%s\n' "SELECT code FROM sms_verification_codes" \
" WHERE phone = '$phone' AND purpose = '$purpose' AND used = 0" \
" AND expired_at > NOW()" \
" ORDER BY created_at DESC, id DESC LIMIT 1;" \
| docker exec -i "$container" sh -c '
# 应用账号凭据必需,缺失则安全失败(绝不回退 root)
if [ -z "${MYSQL_USER:-}" ] || [ -z "${MYSQL_PASSWORD:-}" ]; then
echo "db credentials unavailable" >&2
exit 3
fi
# 密码只经环境变量 MYSQL_PWD 传递,argv 中不出现 -p/--password
MYSQL_PWD="$MYSQL_PASSWORD" exec mysql \
-u"$MYSQL_USER" \
-N -B \
"${MYSQL_DATABASE:-mydreamjob}"' 2>/dev/null \
| head -n 1
}
# 取得验证码:成功时写入 REPLY;绝不打印到 stdout/stderr
sms_code() {
local disabled_msg="test OTP retrieval is disabled"
if ! require_mock_sms_mode || ! require_test_otp_db_read_permission; then
echo "$disabled_msg" >&2
return 1
fi
case "${PHONE:-}" in
*[!0-9]*|'') echo "$disabled_msg" >&2; return 1 ;;
esac esac
[ "${#PHONE}" -eq 11 ] || { echo "$disabled_msg" >&2; return 1; }
# xtrace 已在脚本开头全局关闭,这里无需(也不得)保存/恢复其状态
local code="" qstatus=0
IFS= read -r code < <(otp_db_query "$PHONE" "login") || qstatus=$?
# 校验格式:当前验证码为 6 位数字(app.sms.mock.code-length / CODE_PATTERN)
local valid=1
[ "$qstatus" -eq 0 ] || valid=0
case "$code" in
[0-9][0-9][0-9][0-9][0-9][0-9]) : ;;
*) valid=0 ;;
esac
if [ "$valid" -ne 1 ]; then
unset code
echo "test OTP retrieval failed" >&2
return 1
fi
REPLY="$code"
unset code
return 0
} }
report() { report() {
@ -53,6 +182,23 @@ report() {
echo " http=$http_code ${biz_code:-code=N/A} ${request_id:-requestId=N/A}" echo " http=$http_code ${biz_code:-code=N/A} ${request_id:-requestId=N/A}"
} }
# 仅接收可安全打印的三个字段,不接收任何响应体全文(用于登录等敏感响应)
report_fields() {
local http_code="$1" biz_code="$2" request_id="$3"
echo " http=$http_code ${biz_code:-code=N/A} ${request_id:-requestId=N/A}"
}
# 从响应中只提取可安全打印字段(http / business code / requestId),不保留响应体
extract_status_fields() {
local response="$1"
local http_code body biz_code request_id
http_code="$(printf '%s\n' "$response" | tail -n 1)"
body="$(printf '%s\n' "$response" | sed '$d')"
biz_code="$(printf '%s' "$body" | grep -oE '"code"[[:space:]]*:[[:space:]]*[0-9]+' | head -n 1 | grep -oE '[0-9]+' || true)"
request_id="$(printf '%s' "$body" | grep -oE '"requestId"[[:space:]]*:[[:space:]]*"[^"]*"' | head -n 1 || true)"
printf '%s\n%s\n%s' "$http_code" "$biz_code" "$request_id"
}
extract_token() { extract_token() {
printf '%s' "$1" | sed -n 's/.*"accessToken"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n 1 printf '%s' "$1" | sed -n 's/.*"accessToken"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n 1
} }
@ -61,17 +207,16 @@ extract_id() {
printf '%s' "$1" | sed -n 's/.*"id"[[:space:]]*:[[:space:]]*\([0-9][0-9]*\).*/\1/p' | head -n 1 printf '%s' "$1" | sed -n 's/.*"id"[[:space:]]*:[[:space:]]*\([0-9][0-9]*\).*/\1/p' | head -n 1
} }
sms_code() { # ── 可测试性钩子 ───────────────────────────────────────────
local log_path # 供 shell 单元测试加载函数(验证取码保护与泄漏防护)而不执行完整流程。
log_path="$(resolve_log)" # 正常运行(不设该变量)时完全不生效,不影响任何行为。
[ -n "$log_path" ] || return 1 if [ "${SMOKE_TEST_LIB_ONLY:-}" = "1" ]; then
[ -f "$log_path" ] || return 1 return 0 2>/dev/null || exit 0
grep -a "MOCK SMS CODE.*phone=$PHONE" "$log_path" | tail -n 1 | grep -a -oE 'code=[0-9]{6}' | cut -d= -f2 fi
}
echo "=== MyDreamJob backend smoke test ===" echo "=== MyDreamJob backend smoke test ==="
echo "BASE_URL=$BASE_URL" echo "BASE_URL=$BASE_URL"
echo "PHONE=$PHONE" echo "PHONE=$(mask_phone "$PHONE")"
say "GET /health" say "GET /health"
HEALTH="$(curl -sS -w '\n%{http_code}' "$BASE_URL/health" || true)" HEALTH="$(curl -sS -w '\n%{http_code}' "$BASE_URL/health" || true)"
@ -84,19 +229,33 @@ for attempt in 1 2 3; do
curl -sS -X POST "$BASE_URL/auth/sms/send?phone=$PHONE&purpose=login" \ curl -sS -X POST "$BASE_URL/auth/sms/send?phone=$PHONE&purpose=login" \
-H "X-Request-Id: smoke-sms-$attempt" >/dev/null 2>&1 || true -H "X-Request-Id: smoke-sms-$attempt" >/dev/null 2>&1 || true
sleep 2 sleep 2
SMS="$(sms_code || true)" if sms_code; then
[ -n "$SMS" ] && break SMS="$REPLY"
unset REPLY
[ -n "$SMS" ] && break
fi
done done
if [ -z "$SMS" ]; then if [ -z "$SMS" ]; then
fail "cannot read Mock SMS code; set SERVER_LOG to the server log path" fail "login skipped"
unset SMS
TOKEN="" TOKEN=""
else else
LOGIN="$(curl -sS -w '\n%{http_code}' -X POST "$BASE_URL/auth/login" \ # 请求体经 stdin 传入(--data-binary @-),验证码不出现在进程参数中
LOGIN_BODY="{\"phone\":\"$PHONE\",\"code\":\"$SMS\",\"purpose\":\"login\"}"
LOGIN="$(printf '%s' "$LOGIN_BODY" | curl -sS -w '\n%{http_code}' -X POST "$BASE_URL/auth/login" \
-H 'Content-Type: application/json' \ -H 'Content-Type: application/json' \
-H 'X-Request-Id: smoke-login-001' \ -H 'X-Request-Id: smoke-login-001' \
-d "{\"phone\":\"$PHONE\",\"code\":\"$SMS\",\"purpose\":\"login\"}" || true)" --data-binary @- || true)"
report "$LOGIN" unset LOGIN_BODY
unset SMS
# 只保留可安全打印的三个字段;登录响应全文绝不交给打印函数,也绝不落盘
LOGIN_FIELDS="$(extract_status_fields "$LOGIN")"
TOKEN="$(extract_token "$LOGIN")" TOKEN="$(extract_token "$LOGIN")"
unset LOGIN
report_fields "$(printf '%s' "$LOGIN_FIELDS" | sed -n '1p')" \
"$(printf '%s' "$LOGIN_FIELDS" | sed -n '2p')" \
"$(printf '%s' "$LOGIN_FIELDS" | sed -n '3p')"
unset LOGIN_FIELDS
if [ -n "$TOKEN" ]; then ok "login"; else fail "login"; fi if [ -n "$TOKEN" ]; then ok "login"; else fail "login"; fi
fi fi

144
docs/tests/smoke-test-schema-contract-test.sh

@ -0,0 +1,144 @@
#!/usr/bin/env bash
# S01 第四轮 F6 —— sms_verification_codes 表结构契约门禁
#
# 目的:防止冒烟脚本再次使用**不存在的列**(F6:expires_at),
# 且不允许测试自己维护一份与生产无关的假字段列表来自证正确。
#
# 契约来源(本脚本同时使用前两个,并交叉校验二者一致):
# 1. 实体 @Column:mydreamjob-server/.../auth/SmsVerificationCode.java
# 2. 建表 Flyway:mydreamjob-server/.../db/migration/V1__base_schema.sql
# 3. 真实 MySQL SHOW COLUMNS —— 属**真实验收阶段**,本脚本只输出对账命令,
# 不在自动化阶段伪造该来源。
#
# 重要:expires_at 是 personal_data_export_tasks(V30) / ability_attempts(V35) /
# talent_invitations(V40) 三张表的**真实列**。因此本门禁只对
# sms_verification_codes 这一个表断言"不存在 expires_at",
# 不得写成全库禁令(那会误报,并掩盖真正的字段错误)。
#
# 运行: bash docs/tests/smoke-test-schema-contract-test.sh
set -u
REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
SMOKE="$REPO_ROOT/docs/smoke-test.sh"
ENTITY="$REPO_ROOT/mydreamjob-server/src/main/java/com/mydreamjob/auth/SmsVerificationCode.java"
MIGRATION_DIR="$REPO_ROOT/mydreamjob-server/src/main/resources/db/migration"
FLYWAY="$MIGRATION_DIR/V1__base_schema.sql"
TABLE="sms_verification_codes"
PASS=0
FAIL=0
ok() { echo "[PASS] $1"; PASS=$((PASS + 1)); }
bad() { echo "[FAIL] $1"; FAIL=$((FAIL + 1)); }
check() { if [ "$1" = "1" ]; then ok "$2"; else bad "$2"; fi; }
echo "=== S01 F6: sms_verification_codes 表结构契约门禁 ==="
echo "实体来源 : $ENTITY"
echo "Flyway来源: $FLYWAY"
echo "脚本 : $SMOKE"
echo ""
# ── 契约来源 1:实体 @Column ──────────────────────────────────
entity_cols=""
if [ -f "$ENTITY" ]; then
# 主键 id 在实体中以 @Id 标注(无 @Column(name=...)),单独补入
grep -qE '^[[:space:]]*@Id[[:space:]]*$' "$ENTITY" && entity_cols="id"
entity_cols="$entity_cols $(grep -oE '@Column\(name[[:space:]]*=[[:space:]]*"[A-Za-z0-9_]+"' "$ENTITY" \
| sed 's/.*"\(.*\)"/\1/')"
entity_cols="$(printf '%s' "$entity_cols" | tr ' ' '\n' | grep -v '^$' | sort -u | tr '\n' ' ')"
fi
# ── 契约来源 2:建表 Flyway ───────────────────────────────────
flyway_cols=""
if [ -f "$FLYWAY" ]; then
flyway_cols="$(awk -v t="$TABLE" '
$0 ~ "^CREATE TABLE (IF NOT EXISTS )?" t "([[:space:]]|\\()" { in_t=1; next }
in_t && /^\)/ { in_t=0 }
in_t {
line=$0
sub(/^[[:space:]]+/, "", line)
if (line ~ /^(INDEX|KEY|PRIMARY|CONSTRAINT|UNIQUE|FOREIGN)/) next
if (line ~ /^[A-Za-z0-9_]+[[:space:]]/) { split(line, a, /[[:space:]]/); print a[1] }
}
' "$FLYWAY" | sort -u | tr '\n' ' ')"
fi
check "$([ -f "$ENTITY" ] && echo 1 || echo 0)" "C1. 实体文件存在"
check "$([ -f "$FLYWAY" ] && echo 1 || echo 0)" "C2. 建表 Flyway 存在"
check "$([ -n "$entity_cols" ] && echo 1 || echo 0)" "C3. 实体 @Column 解析出非空列集合"
check "$([ -n "$flyway_cols" ] && echo 1 || echo 0)" "C4. Flyway 解析出非空列集合"
# 两个独立来源必须一致(否则说明解析失败或生产定义本身不一致)
check "$([ "$(printf '%s' "$entity_cols" | tr ' ' '\n' | grep -c .)" -gt 0 ] \
&& [ "$entity_cols" = "$flyway_cols" ] && echo 1 || echo 0)" \
"C5. 实体 @Column 与建表 Flyway 的列集合一致"
echo " 实体列 : ${entity_cols:-<空>}"
echo " Flyway列: ${flyway_cols:-<空>}"
echo ""
# ── 字段存在性(表限定)───────────────────────────────────────
check "$(case " $flyway_cols " in *" expired_at "*) echo 1 ;; *) echo 0 ;; esac)" \
"C6. sms_verification_codes 存在 expired_at 列"
check "$(case " $flyway_cols " in *" expires_at "*) echo 0 ;; *) echo 1 ;; esac)" \
"C7. sms_verification_codes **不存在** expires_at 列"
check "$(case " $entity_cols " in *" expired_at "*) echo 1 ;; *) echo 0 ;; esac)" \
"C8. 实体 expired_at 映射存在"
# ── 反向护栏:expires_at 确实是别的表的真实列 ──────────────────
# 若此条失败,说明有人把它当成了"全库不存在的列"——那会让本门禁退化成误报。
other_tables_with_expires_at=0
for f in "$MIGRATION_DIR"/V30__personal_data_export_tasks.sql \
"$MIGRATION_DIR"/V35__ability_assessment.sql \
"$MIGRATION_DIR"/V40__enterprise_talent_invitations.sql; do
[ -f "$f" ] || continue
grep -qE '^[[:space:]]*expires_at[[:space:]]' "$f" && other_tables_with_expires_at=$((other_tables_with_expires_at + 1))
done
check "$([ "$other_tables_with_expires_at" -gt 0 ] && echo 1 || echo 0)" \
"C9. expires_at 确实是其他表的真实列(故门禁必须按表限定,不得全局禁用)"
# ── 脚本侧:表名与列名 ────────────────────────────────────────
sql_block="$(awk '/SELECT code FROM sms_verification_codes/,/LIMIT 1;/' "$SMOKE" 2>/dev/null)"
# 只保留双引号内的 SQL 片段,剥掉 shell 语法(printf / 引号 / 续行反斜杠),
# 否则 printf、%s 之类会被误当成列名。
sql_text="$(printf '%s' "$sql_block" | grep -oE '"[^"]*"' | tr -d '"' | tr '\n' ' ')"
check "$([ -n "$sql_text" ] && echo 1 || echo 0)" "C10. 能从 smoke-test.sh 提取取码 SQL 文本"
check "$(printf '%s' "$sql_text" | grep -q "FROM $TABLE" && echo 1 || echo 0)" \
"C11. 取码 SQL 查询表为 $TABLE"
check "$(printf '%s' "$sql_text" | grep -q 'expired_at' && echo 1 || echo 0)" \
"C12. 取码 SQL 使用 expired_at"
check "$(printf '%s' "$sql_text" | grep -q 'expires_at' && echo 0 || echo 1)" \
"C13. 取码 SQL **不含** expires_at"
# 脚本 SQL 使用的每个列名都必须落在真实列集合内
used_cols="$(printf '%s' "$sql_text" \
| sed "s/'[^']*'//g" \
| grep -oE '\b[A-Za-z_][A-Za-z0-9_]*\b' \
| grep -viE '^(SELECT|code|FROM|WHERE|AND|OR|ORDER|BY|DESC|ASC|LIMIT|NOW)$|^'$TABLE'$' \
| sort -u | tr '\n' ' ')"
unknown=""
for c in $used_cols; do
case " $flyway_cols " in
*" $c "*) ;;
*) unknown="$unknown $c" ;;
esac
done
check "$([ -z "$unknown" ] && echo 1 || echo 0)" \
"C14. 取码 SQL 使用的列全部存在于 $TABLE${unknown:+(不存在的列:$unknown)}"
# 取码列 code 必须在真实列集合内(正向确认解析没跑偏)
check "$(case " $flyway_cols " in *" code "*) echo 1 ;; *) echo 0 ;; esac)" \
"C15. 取码 SQL 的列 code 是 $TABLE 的真实列"
echo ""
echo "── 真实验收阶段必须执行(本脚本不伪造该来源)───────────────"
echo "docker exec -i <mysql容器> sh -c \\"
echo " 'MYSQL_PWD=\"\$MYSQL_PASSWORD\" mysql -u\"\$MYSQL_USER\" -N -B \"\${MYSQL_DATABASE:-mydreamjob}\" <<<\"SHOW COLUMNS FROM $TABLE;\"'"
echo "对账要求:SHOW COLUMNS 的 Field 集合必须等于上方 Flyway列;"
echo " 必须包含 expired_at;必须不含 expires_at。"
echo ""
echo "============================================"
echo "PASS=$PASS FAIL=$FAIL"
echo "============================================"
[ "$FAIL" -eq 0 ]

548
docs/tests/smoke-test-sms-code-test.sh

@ -0,0 +1,548 @@
#!/usr/bin/env bash
# S01 定向整改 F1 / F4 / F5 —— smoke-test.sh 敏感信息泄漏防护测试
#
# 覆盖:
# F1 取码机制(双重保护 / 不依赖应用日志 / 格式校验 / 清理)
# F4 xtrace 泄漏:**真实以 `bash -x` 执行被测脚本**,捕获 stdout+stderr 并扫描敏感标记
# F4 source 模式不把敏感变量带回调用方
# F5 数据库密码不进入 mysql argv(真实执行容器侧脚本文本,检查假 mysql 的实际 argv)
# §7 文档门禁只针对**活跃文件列表**,不对历史验收报告做全 docs 扫描
#
# 测试使用假的 docker / curl / mysql(PATH 注入),不要求 Docker 或真实 MySQL。
# 仓库内不写入真实验证码、JWT 或数据库口令;测试结束扫描输出确认标记未泄露。
#
# 运行: bash docs/tests/smoke-test-sms-code-test.sh
set -u
REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
SMOKE="$REPO_ROOT/docs/smoke-test.sh"
# ── 固定测试标记(均为假值,非真实凭据)─────────────────────────
TEST_CODE="739251"
TEST_PHONE="19900009999"
TEST_JWT="FAKEJWT_MARKER_QA7X"
TEST_DBPASS="FAKEDBPASS_MARKER_QA7X"
MARKER_MSG="test OTP retrieval is disabled"
TMP="$(mktemp -d)"
FAKEBIN="$TMP/bin"
mkdir -p "$FAKEBIN"
export FAKE_DIR="$TMP"
trap 'rm -rf "$TMP"' EXIT
PASS=0
FAIL=0
ok() { echo "[PASS] $1"; PASS=$((PASS + 1)); }
bad() { echo "[FAIL] $1"; FAIL=$((FAIL + 1)); }
check() { if [ "$1" = "1" ]; then ok "$2"; else bad "$2"; fi; }
# ── 假 docker:真实执行被传入的容器侧 sh -c 脚本(含假 mysql)──────
cat > "$FAKEBIN/docker" <<'FAKEDOCKER'
#!/usr/bin/env bash
# 记录完整 argv(用于 F5 断言:宿主机侧命令行不得含密码)
printf '%s\n' "$*" >> "${FAKE_DIR:?}/docker-argv.log"
script=""
for a in "$@"; do script="$a"; done
# 记录容器侧脚本文本(变量名可以出现,值不可以)
printf '%s\n' "$script" > "${FAKE_DIR}/container-script.txt"
case "${FAKE_DOCKER_MODE:-ok}" in
fail) exit 1 ;;
empty) cat >/dev/null 2>&1 || true; exit 0 ;;
garbage) cat >/dev/null 2>&1 || true; printf '%s\n' "${FAKE_GARBAGE:-not-a-code}"; exit 0 ;;
short) cat >/dev/null 2>&1 || true; printf '%s\n' "12345"; exit 0 ;;
esac
# 真实以本地 sh 执行容器侧脚本,使假 mysql 成为真正被检查 argv 的进程
exec sh -c "$script"
FAKEDOCKER
chmod +x "$FAKEBIN/docker"
# ── 假 mysql:记录 argv 与 stdin(SQL),并按 sms_verification_codes 真实语义作答 ──
# 它**不是**无条件返回验证码:先校验 SQL 使用的列。
# 真实 MySQL 对未知列返回 ERROR 1054 (42S22),这里如实复现,
# 使脚本里字段名写错(例如 expires_at)无法被"总能拿到验证码"掩盖。
#
# FAKE_ROWS 未设置 → 保留历史行为(输出 FAKE_CODE),供 F1/F5 既有用例使用。
# FAKE_ROWS 设置 → 按行套用 WHERE / ORDER BY 语义作答。行格式:
# id|phone|purpose|used|expire_offset_s|created_at|code
# expire_offset_s > 0 表示 expired_at 晚于 NOW();查询用严格大于,故 =0 不命中。
cat > "$FAKEBIN/mysql" <<'FAKEMYSQL'
#!/usr/bin/env bash
printf '%s\n' "$*" > "${FAKE_DIR:?}/mysql-argv.log"
sql="$(cat)"
printf '%s' "$sql" > "${FAKE_DIR}/mysql-stdin.sql"
# ── 表结构契约检查:sms_verification_codes 的真实列是 expired_at ──
case "$sql" in
*sms_verification_codes*)
case "$sql" in
*expires_at*)
printf "ERROR 1054 (42S22): Unknown column 'expires_at' in 'where clause'\n" >&2
exit 1
;;
esac
;;
esac
# 未提供假数据:保持历史行为
if [ -z "${FAKE_ROWS:-}" ]; then
printf '%s\n' "${FAKE_CODE:-}"
exit 0
fi
# 从 SQL 解析查询参数(形态与应用脚本一致)
phone="$(printf '%s' "$sql" | sed -n "s/.*phone *= *'\([^']*\)'.*/\1/p" | head -n 1)"
purpose="$(printf '%s' "$sql" | sed -n "s/.*purpose *= *'\([^']*\)'.*/\1/p" | head -n 1)"
# 逐行套用 WHERE 语义,按 created_at DESC, id DESC 取第一条
best_key="" best_code=""
while IFS='|' read -r rid rphone rpurpose rused roffset rcreated rcode; do
[ -n "${rid:-}" ] || continue
[ "$rphone" = "$phone" ] || continue
[ "$rpurpose" = "$purpose" ] || continue
[ "$rused" = "0" ] || continue
case "$roffset" in
''|*[!0-9-]*) continue ;;
esac
[ "$roffset" -gt 0 ] || continue
key="$(printf '%s|%010d' "$rcreated" "$rid")"
if [ -z "$best_key" ] || [[ "$key" > "$best_key" ]]; then
best_key="$key" best_code="$rcode"
fi
done <<< "${FAKE_ROWS}"
[ -n "$best_code" ] && printf '%s\n' "$best_code"
exit 0
FAKEMYSQL
chmod +x "$FAKEBIN/mysql"
# ── 假 curl:记录请求;登录响应含固定测试 JWT ────────────────────
cat > "$FAKEBIN/curl" <<'FAKECURL'
#!/usr/bin/env bash
url=""; want_stdin=0
for a in "$@"; do
case "$a" in
http*) url="$a" ;;
--data-binary) want_stdin=1 ;;
*@-) want_stdin=1 ;;
esac
done
has_code="no"
if [ "$want_stdin" = "1" ]; then
body="$(cat)"
case "$body" in
*[0-9][0-9][0-9][0-9][0-9][0-9]*) has_code="yes" ;;
esac
fi
case "$url" in
*"/auth/login"*)
echo "login-attempted has6digit=$has_code" >> "${FAKE_DIR:?}/flow.log"
printf '{"code":200,"data":{"accessToken":"%s"}}\n200\n' "${FAKE_JWT:-FAKEJWT}" ;;
*"/auth/sms/send"*) printf '{}\n200\n' ;;
*"/health"*) printf '{"code":200}\n200\n' ;;
*"/notifications/unread-count"*) printf '{"unreadCount":1}\n200\n' ;;
*"/notifications/"*) printf '{"message":"marked as read"}\n200\n' ;;
*"/notifications"*) printf '{"items":[{"id":88}]}\n200\n' ;;
*"/applications/"*) printf '{"status":"VIEWED"}\n200\n' ;;
*"/applications"*) printf '{"id":77}\n200\n' ;;
*"/jobs/public/"*) printf '{"title":"x"}\n200\n' ;;
*"/match"*) printf '{"rulesVersion":"v1"}\n200\n' ;;
*"/jobs"*) printf '{"items":[{"id":11,"title":"x"}]}\n200\n' ;;
*) printf '{}\n200\n' ;;
esac
exit 0
FAKECURL
chmod +x "$FAKEBIN/curl"
export PATH="$FAKEBIN:$PATH"
export FAKE_JWT="$TEST_JWT"
# 数据库凭据标记:让容器侧脚本的凭据前置检查通过,
# 同时用固定标记值验证它绝不出现在 argv / stdout / stderr / 文件中
export MYSQL_USER="smoke_readonly"
export MYSQL_PASSWORD="$TEST_DBPASS"
export MYSQL_DATABASE="mydreamjob"
# 扫描给定文本是否含任一敏感标记;输出命中的标记名(不含值本身)
scan_hits() {
local text="$1" hits=""
case "$text" in *"$TEST_CODE"*) hits="$hits CODE" ;; esac
case "$text" in *"$TEST_PHONE"*) hits="$hits PHONE" ;; esac
case "$text" in *"$TEST_JWT"*) hits="$hits JWT" ;; esac
case "$text" in *"$TEST_DBPASS"*) hits="$hits DBPASS" ;; esac
case "$text" in *"Authorization: Bearer"*) hits="$hits AUTH" ;; esac
case "$text" in *"LOGIN_BODY="*) hits="$hits LOGIN_BODY" ;; esac
printf '%s' "$hits"
}
echo "=== S01 F1/F4/F5: smoke-test.sh 敏感信息泄漏防护测试 ==="
echo "smoke-test.sh: $SMOKE"
echo "固定测试标记已加载(值不打印)。"
echo ""
# ══════════════════════════════════════════════════════════════
# Part 1 — F1 取码保护(进程内,可观测 REPLY)
# ══════════════════════════════════════════════════════════════
run_sms_code() {
local mock="$1" allow="$2" mode="$3" garbage="${4:-}"
local out
out="$(cd "$REPO_ROOT" && bash -c '
set -u
SMOKE_TEST_LIB_ONLY=1
export SMOKE_TEST_LIB_ONLY
if [ "$1" != "__UNSET__" ]; then export SMS_MOCK_ENABLED="$1"; else unset SMS_MOCK_ENABLED; fi
if [ "$2" != "__UNSET__" ]; then export ALLOW_TEST_OTP_DB_READ="$2"; else unset ALLOW_TEST_OTP_DB_READ; fi
export FAKE_DOCKER_MODE="$3"
export FAKE_CODE="'"$TEST_CODE"'"
export FAKE_GARBAGE="$4"
# shellcheck disable=SC1090
. "'"$SMOKE"'"
if sms_code; then
if [ -n "${REPLY:-}" ]; then printf "RESULT=ok\n"; else printf "RESULT=ok_but_empty\n"; fi
unset REPLY
else
printf "RESULT=fail\n"
fi
' _ "$mock" "$allow" "$mode" "$garbage" 2>&1)"
printf '%s' "$out"
}
# ── 以指定假表数据运行 sms_code,并比对取回的验证码 ─────────────
# 只输出 RESULT=ok/fail 与 MATCH=yes/no,**绝不输出验证码本身**。
run_sms_code_with_rows() {
local rows="$1" expect="$2"
local out
out="$(cd "$REPO_ROOT" && bash -c '
set -u
export SMOKE_TEST_LIB_ONLY=1
export SMS_MOCK_ENABLED=true ALLOW_TEST_OTP_DB_READ=true FAKE_DOCKER_MODE=ok
export FAKE_ROWS="$1"
export STUDENT_PHONE="'"$TEST_PHONE"'"
export MYSQL_USER="smoke_readonly" MYSQL_PASSWORD="$2" MYSQL_DATABASE="mydreamjob"
# shellcheck disable=SC1090
. "'"$SMOKE"'"
if sms_code; then
if [ -n "${REPLY:-}" ]; then
printf "RESULT=ok\n"
if [ "${REPLY}" = "$3" ]; then printf "MATCH=yes\n"; else printf "MATCH=no\n"; fi
else
printf "RESULT=ok_but_empty\n"
fi
unset REPLY
else
printf "RESULT=fail\n"
fi
' _ "$rows" "$TEST_DBPASS" "$expect" 2>&1)"
printf '%s' "$out"
}
# ── 端到端运行指定脚本(用于验证「字段错误时不继续登录」)────────
# 脚本置于 $TMP 下镜像 docs/ 的结构,使其相对定位 backend-infra/compose.yaml 生效。
run_full_script() {
local script="$1"
: > "$TMP/flow.log"
(cd "$REPO_ROOT" && \
SMS_MOCK_ENABLED=true ALLOW_TEST_OTP_DB_READ=true FAKE_DOCKER_MODE=ok \
FAKE_CODE="$TEST_CODE" FAKE_JWT="$TEST_JWT" \
MYSQL_PASSWORD="$TEST_DBPASS" MYSQL_USER="smoke_readonly" MYSQL_DATABASE="mydreamjob" \
STUDENT_PHONE="$TEST_PHONE" BASE_URL="http://127.0.0.1:1/api/v1" \
bash "$script" 2>&1) || true
}
r="$(run_sms_code true true ok)"
check "$(printf '%s' "$r" | grep -q 'RESULT=ok' && echo 1 || echo 0)" "1. mock=true且ALLOW=true时可取得测试验证码"
check "$(printf '%s' "$r" | grep -q "$TEST_CODE" && echo 0 || echo 1)" "10a. 取码调用 stdout/stderr 不含验证码"
r="$(run_sms_code false true ok)"
check "$(printf '%s' "$r" | grep -q 'RESULT=fail' && echo 1 || echo 0)" "2. mock=false 时拒绝查询"
check "$(printf '%s' "$r" | grep -qF "$MARKER_MSG" && echo 1 || echo 0)" "2b. 拒绝时输出固定提示"
r="$(run_sms_code __UNSET__ true ok)"
check "$(printf '%s' "$r" | grep -q 'RESULT=fail' && echo 1 || echo 0)" "3. 配置缺失时拒绝查询"
r="$(run_sms_code true __UNSET__ ok)"
check "$(printf '%s' "$r" | grep -q 'RESULT=fail' && echo 1 || echo 0)" "4. ALLOW_TEST_OTP_DB_READ 缺失时拒绝查询"
r="$(run_sms_code true false ok)"
check "$(printf '%s' "$r" | grep -q 'RESULT=fail' && echo 1 || echo 0)" "5. ALLOW_TEST_OTP_DB_READ=false 时拒绝查询"
r="$(run_sms_code true true empty)"
check "$(printf '%s' "$r" | grep -q 'RESULT=fail' && echo 1 || echo 0)" "6. 没有可用验证码时安全失败"
r="$(run_sms_code true true fail)"
check "$(printf '%s' "$r" | grep -q 'RESULT=fail' && echo 1 || echo 0)" "7. 查询失败时不继续"
r="$(run_sms_code true true garbage 'not-a-code')"
check "$(printf '%s' "$r" | grep -q 'RESULT=fail' && echo 1 || echo 0)" "8a. 非数字结果被拒绝"
r="$(run_sms_code true true short)"
check "$(printf '%s' "$r" | grep -q 'RESULT=fail' && echo 1 || echo 0)" "8b. 长度非法结果被拒绝"
# ══════════════════════════════════════════════════════════════
# Part 2 — F4:真实 `bash -x` 执行,扫描 stdout+stderr
# ══════════════════════════════════════════════════════════════
run_smoke_xtrace() {
local mode="$1"
: > "$TMP/flow.log"; : > "$TMP/docker-argv.log"; : > "$TMP/mysql-argv.log"
(cd "$REPO_ROOT" && \
SMS_MOCK_ENABLED=true ALLOW_TEST_OTP_DB_READ=true FAKE_DOCKER_MODE="$mode" \
FAKE_CODE="$TEST_CODE" FAKE_JWT="$TEST_JWT" \
MYSQL_PASSWORD="$TEST_DBPASS" MYSQL_USER="smoke_readonly" MYSQL_DATABASE="mydreamjob" \
STUDENT_PHONE="$TEST_PHONE" BASE_URL="http://127.0.0.1:1/api/v1" \
bash -x "$SMOKE" 2>&1) || true
}
out_x="$(run_smoke_xtrace ok)"
hits_x="$(scan_hits "$out_x")"
check "$(printf '%s' "$hits_x" | grep -q 'CODE' && echo 0 || echo 1)" "F4-1. bash -x 下不泄露验证码"
check "$(printf '%s' "$hits_x" | grep -q 'PHONE' && echo 0 || echo 1)" "F4-2. bash -x 下不泄露完整手机号"
check "$(printf '%s' "$hits_x" | grep -q 'JWT' && echo 0 || echo 1)" "F4-3. bash -x 下不泄露 JWT"
check "$(printf '%s' "$hits_x" | grep -q 'AUTH' && echo 0 || echo 1)" "F4-4. bash -x 下不泄露 Authorization 头"
check "$(printf '%s' "$hits_x" | grep -q 'DBPASS' && echo 0 || echo 1)" "F4-5. bash -x 下不泄露数据库密码"
check "$(printf '%s' "$hits_x" | grep -q 'LOGIN_BODY' && echo 0 || echo 1)" "F4-6. bash -x 下不泄露 LOGIN_BODY"
# 正常流程仍可完成(登录被调用且带 6 位码;登录判定 PASS)
check "$(grep -q 'login-attempted has6digit=yes' "$TMP/flow.log" 2>/dev/null && echo 1 || echo 0)" \
"F4-7. bash -x 下正常流程仍可完成登录"
check "$(printf '%s' "$out_x" | grep -q '\[PASS\] login' && echo 1 || echo 0)" \
"F4-8. bash -x 下登录步骤判定 PASS"
# xtrace 未在脚本内被重新启用:脚本内不存在可执行的 set -x
setx_code="$(grep -n 'set -x' "$SMOKE" | grep -v '^[0-9]*:[[:space:]]*#' | wc -l)"
check "$([ "$setx_code" -eq 0 ] && echo 1 || echo 0)" "F4-9. 脚本中不存在可执行的 set -x(不会恢复 xtrace)"
# 失败流程同样不泄露
out_f="$(run_smoke_xtrace empty)"
hits_f="$(scan_hits "$out_f")"
check "$([ -z "$hits_f" ] && echo 1 || echo 0)" "F4-10. 失败流程(无可用验证码)同样不泄露敏感值"
# ══════════════════════════════════════════════════════════════
# Part 3 — F4:source 模式不把敏感值带回调用方
# ══════════════════════════════════════════════════════════════
src_out="$(cd "$REPO_ROOT" && bash -c '
set -u
export SMOKE_TEST_LIB_ONLY=1
export SMS_MOCK_ENABLED=true ALLOW_TEST_OTP_DB_READ=true
export FAKE_DOCKER_MODE=ok FAKE_CODE="'"$TEST_CODE"'"
. "'"$SMOKE"'"
trap -p EXIT > "'"$TMP"'/trap.txt" 2>/dev/null || true
if sms_code; then
if [ -n "${REPLY:-}" ]; then echo "reply-nonempty"; else echo "reply-empty"; fi
unset REPLY
fi
# 清理后不得残留任何非空敏感变量
leak=0
for v in REPLY SMS OTP LOGIN_BODY LOGIN TOKEN AUTH; do
eval "val=\${$v:-}"
[ -n "$val" ] && leak=1
done
echo "leak=$leak"
' 2>&1)"
check "$(printf '%s' "$src_out" | grep -q 'reply-nonempty' && echo 1 || echo 0)" "F4-11. source 模式下取码契约(REPLY)可用"
check "$(printf '%s' "$src_out" | grep -q 'leak=0' && echo 1 || echo 0)" "F4-12. source 模式下清理后无敏感变量残留"
check "$(grep -q 'cleanup_secrets' "$TMP/trap.txt" 2>/dev/null && echo 1 || echo 0)" "F4-13. source 模式下 EXIT trap 已登记清理函数"
check "$(grep -q 'PHONE' "$SMOKE" && grep -q 'TOKEN' "$SMOKE" && grep -q 'AUTH' "$SMOKE" && grep -q 'COMPANY_TOKEN' "$SMOKE" && echo 1 || echo 0)" \
"F4-14. cleanup_secrets 覆盖 PHONE/TOKEN/AUTH/COMPANY_TOKEN"
# ══════════════════════════════════════════════════════════════
# Part 4 — F5:数据库密码不进入 mysql argv
# ══════════════════════════════════════════════════════════════
: > "$TMP/mysql-argv.log"; : > "$TMP/docker-argv.log"
_f5="$(run_sms_code true true ok)"
mysql_argv="$(cat "$TMP/mysql-argv.log" 2>/dev/null)"
docker_argv="$(cat "$TMP/docker-argv.log" 2>/dev/null)"
container_script="$(cat "$TMP/container-script.txt" 2>/dev/null)"
mysql_stdin="$(cat "$TMP/mysql-stdin.sql" 2>/dev/null)"
check "$(printf '%s' "$mysql_argv" | grep -qF "$TEST_DBPASS" && echo 0 || echo 1)" "F5-1. mysql argv 不含数据库密码值"
check "$(printf '%s' "$docker_argv" | grep -qF "$TEST_DBPASS" && echo 0 || echo 1)" "F5-2. 宿主机 docker 命令行不含数据库密码值"
check "$(printf '%s' "$container_script" | grep -qF "$TEST_DBPASS" && echo 0 || echo 1)" "F5-3. 容器侧脚本文本不含密码值"
check "$(printf '%s' "$container_script" | grep -q 'MYSQL_PASSWORD' && echo 1 || echo 0)" "F5-4. 容器侧脚本以变量名引用 MYSQL_PASSWORD"
check "$(printf '%s' "$mysql_argv" | grep -qE '(^| )-p' && echo 0 || echo 1)" "F5-5. mysql argv 不含 -p<密码>"
check "$(printf '%s' "$mysql_argv" | grep -q -- '--password' && echo 0 || echo 1)" "F5-6. mysql argv 不含 --password"
check "$(printf '%s' "$container_script" | grep -q 'MYSQL_ROOT_PASSWORD' && echo 0 || echo 1)" "F5-7. 不使用 root 凭据(无 MYSQL_ROOT_PASSWORD)"
check "$(printf '%s' "$container_script" | grep -q 'uroot' && echo 0 || echo 1)" "F5-8. 不使用 -uroot"
check "$(printf '%s' "$mysql_argv" | grep -q 'smoke_readonly' && echo 1 || echo 0)" "F5-9. 使用应用账号 MYSQL_USER"
check "$(printf '%s' "$mysql_argv" | grep -q 'mydreamjob' && echo 1 || echo 0)" "F5-10. 使用 MYSQL_DATABASE"
check "$(printf '%s' "$mysql_stdin" | grep -qi '^SELECT' && echo 1 || echo 0)" "F5-11. SQL 经 stdin 传入"
check "$(printf '%s' "$mysql_stdin" | grep -qiE 'INSERT|UPDATE|DELETE|DROP|ALTER' && echo 0 || echo 1)" "F5-12. 查询仅包含 SELECT(无写操作)"
check "$(printf '%s' "$mysql_argv" | grep -q 'MYSQL_PWD\|MYSQL_PASSWORD' && echo 0 || echo 1)" "F5-13. 密码不经 argv 传递"
check "$(printf '%s' "$container_script" | grep -q 'MYSQL_PWD' && echo 1 || echo 0)" "F5-14. 密码经 MYSQL_PWD 环境变量传递"
check "$(printf '%s' "$container_script" | grep -q 'MYSQL_PASSWORD' && grep -q 'exit 3' "$TMP/container-script.txt" && echo 1 || echo 0)" \
"F5-15. 凭据缺失时安全失败(不回退 root)"
check "$(printf '%s' "$_f5" | grep -q 'RESULT=ok' && echo 1 || echo 0)" "F5-16. argv 安全前提下取码仍可用"
# docker 不可用(容器不存在)时不泄露且安全失败
: > "$TMP/mysql-argv.log"
r="$(run_sms_code true true fail)"
check "$(printf '%s' "$r" | grep -q 'RESULT=fail' && echo 1 || echo 0)" "F5-17. 查询失败时安全失败"
check "$(printf '%s' "$r" | grep -q "$TEST_DBPASS" && echo 0 || echo 1)" "F5-18. 失败路径 stderr 不含数据库密码"
# ══════════════════════════════════════════════════════════════
# Part 5 — F1 静态保证 + §7 活跃文件文档门禁
# ══════════════════════════════════════════════════════════════
check "$(grep -qE 'grep[^|]*\.log|grep[^|]*stdout' "$SMOKE" && echo 0 || echo 1)" "13. smoke-test.sh 不 grep 应用日志"
check "$(grep -q 'MOCK SMS CODE' "$SMOKE" && echo 0 || echo 1)" "14. smoke-test.sh 中不存在 MOCK SMS CODE"
mech=1
grep -q 'unset REPLY' "$SMOKE" || mech=0
grep -q 'unset SMS' "$SMOKE" || mech=0
grep -q 'trap cleanup_secrets EXIT' "$SMOKE" || mech=0
grep -q 'unset LOGIN_BODY' "$SMOKE" || mech=0
check "$mech" "15. 存在 REPLY/SMS/LOGIN_BODY 清理与 EXIT trap"
check "$(grep -qE 'curl.*(-v|--trace|--trace-ascii)' "$SMOKE" && echo 0 || echo 1)" "20. 未使用 curl -v/--trace/--trace-ascii"
check "$(grep -q 'report_fields' "$SMOKE" && echo 1 || echo 0)" "21. 登录响应经 report_fields(不把响应全文交给打印函数)"
# §7:仅扫描**活跃操作文档与正式代码**,不把历史验收报告计入
ACTIVE_FILES="docs/acceptance-week1.md docs/api-v0.1.md docs/frontend-integration.md docs/offer-api.md docs/resume-api.md docs/smoke-test.sh README.md"
active_viol=0
for f in $ACTIVE_FILES; do
[ -f "$REPO_ROOT/$f" ] || continue
c=$(grep -c "验证码.*打印到日志" "$REPO_ROOT/$f" 2>/dev/null); c=${c:-0}
c2=$(grep -c "从日志获取验证码" "$REPO_ROOT/$f" 2>/dev/null); c2=${c2:-0}
c3=$(grep -c "MOCK SMS CODE" "$REPO_ROOT/$f" 2>/dev/null); c3=${c3:-0}
[ $((c + c2 + c3)) -ne 0 ] && active_viol=$((active_viol + 1))
done
check "$([ "$active_viol" -eq 0 ] && echo 1 || echo 0)" "22. 活跃操作文档不存在『从日志取码』有效指导"
src_viol=$(grep -rn "MOCK SMS CODE" "$REPO_ROOT/mydreamjob-server/src/main/java" 2>/dev/null | wc -l)
check "$([ "$src_viol" -eq 0 ] && echo 1 || echo 0)" "23. 正式 Java 代码中不存在 MOCK SMS CODE"
hist_ok=1
[ -f "$REPO_ROOT/docs/s01-independent-mysql-acceptance-20260911.md" ] || hist_ok=0
[ -f "$REPO_ROOT/docs/s01-remediation-f1f2f3-20260911.md" ] || hist_ok=0
check "$hist_ok" "24. 历史验收报告仍保留(作为历史失败证据,不参与活跃文档扫描)"
# ══════════════════════════════════════════════════════════════
# Part 6 — F6:SQL 字段契约(expired_at)与查询语义
# ══════════════════════════════════════════════════════════════
# 背景:expires_at 在本仓库中是 personal_data_export_tasks(V30)、
# ability_attempts(V35)、talent_invitations(V40) 三张表的**真实列**;
# 只有 sms_verification_codes 使用 expired_at。因此断言必须限定在该表的查询上,
# **不能**写成"全库不存在 expires_at"——那会误报。
#
# 真实列清单从**创建该表的 Flyway** 派生,不在测试里手工维护一份与生产无关的
# 假字段列表来自证正确。
FLYWAY="$REPO_ROOT/mydreamjob-server/src/main/resources/db/migration/V1__base_schema.sql"
real_cols="$(awk '
/^CREATE TABLE (IF NOT EXISTS )?sms_verification_codes/ { in_t=1; next }
in_t && /^\)/ { in_t=0 }
in_t {
line=$0
sub(/^[[:space:]]+/, "", line)
if (line ~ /^(INDEX|KEY|PRIMARY|CONSTRAINT|UNIQUE|FOREIGN)/) next
if (line ~ /^[a-z_]+[[:space:]]/) { split(line, a, /[[:space:]]/); print a[1] }
}
' "$FLYWAY" | sort -u | tr '\n' ' ')"
# 取一次真实查询文本(此调用未设置 FAKE_ROWS,走历史路径)
: > "$TMP/mysql-stdin.sql"
_f6="$(run_sms_code true true ok)"
sql_text="$(cat "$TMP/mysql-stdin.sql" 2>/dev/null)"
check "$(printf '%s' "$sql_text" | grep -q 'expired_at > NOW()' && echo 1 || echo 0)" \
"F6-1. otp_db_query 使用 expired_at > NOW()"
check "$(printf '%s' "$sql_text" | grep -q 'expires_at' && echo 0 || echo 1)" \
"F6-2. otp_db_query 不再出现错误字段 expires_at"
# 解析查询里使用的列名(去掉字符串字面量与 SQL 关键字)
used_cols="$(printf '%s' "$sql_text" \
| sed "s/'[^']*'//g" \
| grep -oE '\b[a-z_][a-z0-9_]*\b' \
| grep -vEx 'select|from|where|and|or|order|by|desc|asc|limit|now|sms_verification_codes' \
| sort -u)"
unknown_cols=""
for c in $used_cols; do
case " $real_cols " in
*" $c "*) ;;
*) unknown_cols="$unknown_cols $c" ;;
esac
done
check "$([ -z "$unknown_cols" ] && echo 1 || echo 0)" \
"F6-3. 查询只使用 sms_verification_codes 的真实列${unknown_cols:+(命中不存在的列:$unknown_cols)}"
# 防止"派生失败得到空清单"导致上一条恒真
check "$(printf '%s' "$real_cols" | grep -q 'expired_at' && echo 1 || echo 0)" \
"F6-4. 真实列清单由 Flyway 派生且包含 expired_at(非空、非自证)"
check "$(printf '%s' "$sql_text" | grep -qiE '^SELECT code FROM sms_verification_codes' && echo 1 || echo 0)" \
"F6-5. 假 mysql 捕获到完整 stdin SQL(不是无条件返回验证码)"
# 字段错误 → 假 mysql 必须失败(复现真实 MySQL 的 ERROR 1054)
printf '%s\n' "SELECT code FROM sms_verification_codes WHERE phone='x' AND expires_at > NOW();" \
| "$FAKEBIN/mysql" >/dev/null 2>"$TMP/err1054.txt" || rc1054=$?
rc1054="${rc1054:-0}"
check "$([ "$rc1054" -ne 0 ] && echo 1 || echo 0)" \
"F6-6. SQL 字段错误时假 mysql 返回失败(非 0)"
check "$(grep -q '1054' "$TMP/err1054.txt" 2>/dev/null && echo 1 || echo 0)" \
"F6-6b. 字段错误时报 ERROR 1054 (42S22)"
# 端到端:把字段改错的副本跑完整流程,验证不会继续登录
BUGDIR="$TMP/bugrepo"
mkdir -p "$BUGDIR/docs" "$BUGDIR/backend-infra"
cp "$REPO_ROOT/backend-infra/compose.yaml" "$BUGDIR/backend-infra/compose.yaml" 2>/dev/null || true
sed 's/expired_at > NOW()/expires_at > NOW()/' "$SMOKE" > "$BUGDIR/docs/smoke-test.sh"
out_bug="$(run_full_script "$BUGDIR/docs/smoke-test.sh")"
check "$(printf '%s' "$out_bug" | grep -q '\[FAIL\] login skipped' && echo 1 || echo 0)" \
"F6-7. 字段错误时 sms_code() 不继续登录"
check "$(grep -q 'login-attempted' "$TMP/flow.log" 2>/dev/null && echo 0 || echo 1)" \
"F6-7b. 字段错误时未发出任何 login 请求"
# 正确字段 → 取码成功并可完成登录
out_ok="$(run_full_script "$SMOKE")"
check "$(grep -q 'login-attempted has6digit=yes' "$TMP/flow.log" 2>/dev/null && echo 1 || echo 0)" \
"F6-8. 正确字段时取码成功并完成登录"
# ── 过期 / used / purpose / phone / 最新一条 语义 ──────────────
# 行格式 id|phone|purpose|used|expire_offset_s|created_at|code
P="$TEST_PHONE"
R_FUTURE="1|$P|login|0|300|2026-09-11 10:00:00|$TEST_CODE"
R_EXACT="1|$P|login|0|0|2026-09-11 10:00:00|$TEST_CODE"
R_PAST="1|$P|login|0|-300|2026-09-11 10:00:00|$TEST_CODE"
R_USED="1|$P|login|1|300|2026-09-11 10:00:00|$TEST_CODE"
R_PURPOSE="1|$P|register|0|300|2026-09-11 10:00:00|$TEST_CODE"
R_PHONE="1|19900001111|login|0|300|2026-09-11 10:00:00|$TEST_CODE"
R_OLD="1|$P|login|0|300|2026-09-11 10:00:00|111111"
R_NEW="2|$P|login|0|300|2026-09-11 11:00:00|222222"
r="$(run_sms_code_with_rows "$R_EXACT" "$TEST_CODE")"
check "$(printf '%s' "$r" | grep -q 'RESULT=fail' && echo 1 || echo 0)" \
"F6-9. expired_at 恰好等于 NOW() 时不得取出"
r="$(run_sms_code_with_rows "$R_FUTURE" "$TEST_CODE")"
check "$(printf '%s' "$r" | grep -q 'MATCH=yes' && echo 1 || echo 0)" \
"F6-10. expired_at 晚于 NOW() 时可以取出"
r="$(run_sms_code_with_rows "$R_PAST" "$TEST_CODE")"
check "$(printf '%s' "$r" | grep -q 'RESULT=fail' && echo 1 || echo 0)" \
"F6-11. expired_at 早于 NOW() 时不得取出"
r="$(run_sms_code_with_rows "$R_USED" "$TEST_CODE")"
check "$(printf '%s' "$r" | grep -q 'RESULT=fail' && echo 1 || echo 0)" \
"F6-12. used=1 不得取出"
r="$(run_sms_code_with_rows "$R_PURPOSE" "$TEST_CODE")"
check "$(printf '%s' "$r" | grep -q 'RESULT=fail' && echo 1 || echo 0)" \
"F6-13. purpose 不匹配不得取出"
r="$(run_sms_code_with_rows "$R_PHONE" "$TEST_CODE")"
check "$(printf '%s' "$r" | grep -q 'RESULT=fail' && echo 1 || echo 0)" \
"F6-14. phone 不匹配不得取出"
r="$(run_sms_code_with_rows "$(printf '%s\n%s' "$R_OLD" "$R_NEW")" "222222")"
check "$(printf '%s' "$r" | grep -q 'MATCH=yes' && echo 1 || echo 0)" \
"F6-15. 多条有效记录只取 created_at/id 最新一条"
# 逆序输入仍取同一条,证明按 ORDER BY 取值而非"最后一行胜出"
r="$(run_sms_code_with_rows "$(printf '%s\n%s' "$R_NEW" "$R_OLD")" "222222")"
check "$(printf '%s' "$r" | grep -q 'MATCH=yes' && echo 1 || echo 0)" \
"F6-15b. 与输入顺序无关,按 created_at/id 排序取值"
echo ""
echo "============================================"
echo "PASS=$PASS FAIL=$FAIL"
echo "============================================"
# 最终泄露扫描(测试自身输出也不得含标记)
if printf '%s' "$out_x$out_f$r$src_out" | grep -qF "$TEST_CODE"; then
echo "LEAK DETECTED: code"; exit 1
fi
if printf '%s' "$out_x$out_f$r$src_out" | grep -qF "$TEST_JWT"; then
echo "LEAK DETECTED: jwt"; exit 1
fi
if printf '%s' "$out_x$out_f$r$src_out" | grep -qF "$TEST_DBPASS"; then
echo "LEAK DETECTED: dbpass"; exit 1
fi
[ "$FAIL" -eq 0 ]

3
mydreamjob-server/src/main/java/com/mydreamjob/auth/AuthService.java

@ -57,7 +57,8 @@ public class AuthService {
.verified(true) .verified(true)
.build(); .build();
user = userRepository.save(user); user = userRepository.save(user);
log.info("Auto-created user: id={}, phone={}", user.getId(), phone); // 日志不得记录完整手机号
log.info("Auto-created user: id={}", user.getId());
} else { } else {
user = userOpt.get(); user = userOpt.get();
} }

18
mydreamjob-server/src/main/java/com/mydreamjob/auth/JwtAuthenticationFilter.java

@ -34,13 +34,19 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
if (token != null && tokenProvider.validateToken(token)) { if (token != null && tokenProvider.validateToken(token)) {
try { try {
Long userId = tokenProvider.parseUserId(token); Long userId = tokenProvider.parseUserId(token);
UsernamePasswordAuthenticationToken authentication = if (userId == null || userId <= 0) {
new UsernamePasswordAuthenticationToken(userId, null, Collections.emptyList()); // 非法主体一律不建立认证;不回显 subject
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); log.warn("JWT authentication rejected: invalid subject");
SecurityContextHolder.getContext().setAuthentication(authentication); } else {
log.debug("Authenticated user {} from JWT", userId); UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(userId, null, Collections.emptyList());
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authentication);
log.debug("Authenticated user {} from JWT", userId);
}
} catch (Exception e) { } catch (Exception e) {
log.warn("JWT authentication failed for token: {}", e.getMessage()); // 固定安全文本:异常消息可能包含非法 principal 原文,一律不回显
log.warn("JWT authentication rejected: unparsable subject");
} }
} }
filterChain.doFilter(request, response); filterChain.doFilter(request, response);

35
mydreamjob-server/src/main/java/com/mydreamjob/auth/SmsVerificationService.java

@ -1,6 +1,7 @@
package com.mydreamjob.auth; package com.mydreamjob.auth;
import com.mydreamjob.audit.AuditService; import com.mydreamjob.audit.AuditService;
import com.mydreamjob.auth.sms.SmsCodeSender;
import com.mydreamjob.common.exception.BusinessException; import com.mydreamjob.common.exception.BusinessException;
import com.mydreamjob.common.exception.ErrorCode; import com.mydreamjob.common.exception.ErrorCode;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
@ -18,8 +19,10 @@ import java.util.regex.Pattern;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
/** /**
* Mock 短信验证码服务 * 短信验证码服务。
* 不接入真实短信网关,验证码直接生成并存入 Redis *
* <p>验证码只写入短信验证码表,并通过 {@link SmsCodeSender} 投递;
* <strong>任何级别的日志都不得包含验证码或完整手机号</strong>。</p>
*/ */
@Slf4j @Slf4j
@Service @Service
@ -29,6 +32,7 @@ public class SmsVerificationService {
private final SmsVerificationCodeRepository codeRepository; private final SmsVerificationCodeRepository codeRepository;
private final RedisTemplate<String, Object> redisTemplate; private final RedisTemplate<String, Object> redisTemplate;
private final AuditService auditService; private final AuditService auditService;
private final SmsCodeSender smsCodeSender;
@Value("${app.sms.mock.code-length:6}") @Value("${app.sms.mock.code-length:6}")
private int codeLength; private int codeLength;
@ -49,7 +53,10 @@ public class SmsVerificationService {
private static final Pattern CODE_PATTERN = Pattern.compile("[0-9]{6}"); private static final Pattern CODE_PATTERN = Pattern.compile("[0-9]{6}");
/** /**
* 发送验证码(Mock) * 发送验证码。
*
* <p>发送动作委派给 {@link SmsCodeSender}:
* {@code app.sms.mock.enabled=false} 或未配置时使用不可用实现,安全失败并回滚本次验证码落库。</p>
*/ */
@Transactional @Transactional
public void sendCode(String phone, String purpose, public void sendCode(String phone, String purpose,
@ -59,14 +66,14 @@ public class SmsVerificationService {
// 检查锁定 // 检查锁定
String lockoutKey = LOCKOUT_KEY_PREFIX + phone; String lockoutKey = LOCKOUT_KEY_PREFIX + phone;
if (redisTemplate.hasKey(lockoutKey)) { if (redisTemplate.hasKey(lockoutKey)) {
log.warn("Phone {} is locked out", phone); log.warn("SMS send rejected: phone locked out, phone={}", maskPhone(phone));
throw new BusinessException(ErrorCode.SMS_RATE_LIMITED); throw new BusinessException(ErrorCode.SMS_RATE_LIMITED);
} }
// 检查频率限制(60秒内最多1次) // 检查频率限制(60秒内最多1次)
String rateLimitKey = RATE_LIMIT_KEY_PREFIX + phone; String rateLimitKey = RATE_LIMIT_KEY_PREFIX + phone;
if (redisTemplate.hasKey(rateLimitKey)) { if (redisTemplate.hasKey(rateLimitKey)) {
log.warn("Phone {} rate limited", phone); log.warn("SMS send rejected: rate limited, phone={}", maskPhone(phone));
throw new BusinessException(ErrorCode.SMS_RATE_LIMITED); throw new BusinessException(ErrorCode.SMS_RATE_LIMITED);
} }
@ -89,14 +96,26 @@ public class SmsVerificationService {
// 设置频率限制 // 设置频率限制
redisTemplate.opsForValue().set(rateLimitKey, "1", 60, TimeUnit.SECONDS); redisTemplate.opsForValue().set(rateLimitKey, "1", 60, TimeUnit.SECONDS);
// Mock: 打印验证码到日志(不发送真实短信) // 投递验证码:实现方保证不把验证码/完整手机号写入日志。
log.info("MOCK SMS CODE: phone={}, purpose={}, code={}", phone, purpose, code); // 不可用实现会在此抛出 SERVICE_UNAVAILABLE,令本次事务回滚(不产生虚假的“发送成功”)。
smsCodeSender.sendVerificationCode(phone, purpose, code);
// 审计明细同样不得记录完整手机号
auditService.log(null, "SMS_SEND", "PHONE", null, auditService.log(null, "SMS_SEND", "PHONE", null,
"Mock SMS sent to " + phone + " for " + purpose, "SMS send accepted; phone=" + maskPhone(phone) + "; purpose=" + purpose,
ipAddress, userAgent, requestId); ipAddress, userAgent, requestId);
} }
/**
* 手机号脱敏:仅保留前 3 位与后 4 位,用于日志与审计明细。
*/
private String maskPhone(String phone) {
if (phone == null || phone.length() < 7) {
return "***";
}
return phone.substring(0, 3) + "****" + phone.substring(phone.length() - 4);
}
/** /**
* 验证验证码 * 验证验证码
* 按手机号+用途查询最新一条未使用记录,原子性标记已使用。 * 按手机号+用途查询最新一条未使用记录,原子性标记已使用。

23
mydreamjob-server/src/main/java/com/mydreamjob/auth/sms/MockSmsCodeSender.java

@ -0,0 +1,23 @@
package com.mydreamjob.auth.sms;
import lombok.extern.slf4j.Slf4j;
/**
* Mock 短信发送实现。
*
* <p>由 {@link SmsSenderConfiguration} 在 {@code app.sms.mock.enabled} 显式解析为
* {@code true} 时装配(不再使用条件注解,以保证任何取值下恰好一个 Bean)。</p>
*
* <p>不接入真实短信网关,仅记录一条固定文本的发送事件。
* <strong>不得</strong>把验证码或完整手机号写入日志。</p>
*/
@Slf4j
public class MockSmsCodeSender implements SmsCodeSender {
@Override
public void sendVerificationCode(String phone, String purpose, String code) {
// 固定安全文本:不包含验证码、手机号、Token 或任何配置细节。
// 本地开发如需验证码,请从 sms_verification_codes 表读取(见 docs 说明),不要重新写回日志。
log.info("SMS verification code generated (mock sender)");
}
}

24
mydreamjob-server/src/main/java/com/mydreamjob/auth/sms/SmsCodeSender.java

@ -0,0 +1,24 @@
package com.mydreamjob.auth.sms;
/**
* 短信验证码发送抽象。
*
* <p>由 {@code app.sms.mock.enabled} 决定加载哪一个实现:
* <ul>
* <li>{@code true} → {@link MockSmsCodeSender}(本地/测试环境,不调用外部网关)</li>
* <li>{@code false} 或未配置 → {@link UnavailableSmsCodeSender}(安全失败,不伪造成功)</li>
* </ul>
*
* <p>实现方<strong>禁止</strong>把验证码或完整手机号写入任何级别的日志。
*/
public interface SmsCodeSender {
/**
* 发送验证码。
*
* @param phone 接收手机号(实现方不得写入日志)
* @param purpose 验证码用途
* @param code 验证码(实现方不得写入日志)
*/
void sendVerificationCode(String phone, String purpose, String code);
}

37
mydreamjob-server/src/main/java/com/mydreamjob/auth/sms/SmsSenderConfiguration.java

@ -0,0 +1,37 @@
package com.mydreamjob.auth.sms;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* 短信发送实现装配(单 Bean 方案)。
*
* <p>由本配置类<strong>始终且仅</strong>产生一个 {@link SmsCodeSender},从而彻底避免
* “两个互斥 {@code @ConditionalOnProperty} 在空值/空白值下都不匹配 → 0 个 Bean →
* 模糊 Spring 装配错误导致启动失败”的问题。</p>
*
* <p>取值语义(大小写不敏感,两侧空白忽略):</p>
* <ul>
* <li>{@code true} → {@link MockSmsCodeSender}</li>
* <li>{@code false} / 缺失 / 空字符串 / 仅空白 / 其他非法值 → {@link UnavailableSmsCodeSender}</li>
* </ul>
*
* <p>安全约束:只有显式 {@code true} 才启用 Mock;非法值一律安全回退到不可用实现,
* 既不启用 Mock,也不进入发送流程。本类不输出配置原值到日志。</p>
*/
@Configuration
public class SmsSenderConfiguration {
/** 显式启用 Mock 的唯一取值。 */
private static final String MOCK_ENABLED_VALUE = "true";
@Bean
public SmsCodeSender smsCodeSender(
@Value("${app.sms.mock.enabled:false}") String rawEnabled) {
if (rawEnabled != null && MOCK_ENABLED_VALUE.equalsIgnoreCase(rawEnabled.trim())) {
return new MockSmsCodeSender();
}
return new UnavailableSmsCodeSender();
}
}

25
mydreamjob-server/src/main/java/com/mydreamjob/auth/sms/UnavailableSmsCodeSender.java

@ -0,0 +1,25 @@
package com.mydreamjob.auth.sms;
import com.mydreamjob.common.exception.BusinessException;
import com.mydreamjob.common.exception.ErrorCode;
/**
* 不可用短信发送实现。
*
* <p>由 {@link SmsSenderConfiguration} 在 {@code app.sms.mock.enabled} 为 false、缺失、
* 空值、仅空白或其他非法值时装配(不再使用条件注解,以保证任何取值下恰好一个 Bean)。</p>
*
* <p>当前未接入真实短信网关,因此本实现<strong>安全失败</strong>:
* 抛出既有的“服务不可用”业务异常,绝不返回虚假的“发送成功”。
* 异常消息为固定安全文本,不含手机号、验证码或任何配置细节。</p>
*/
public class UnavailableSmsCodeSender implements SmsCodeSender {
/** 固定安全文本:不含手机号、验证码、Token、内网地址或配置项。 */
static final String SAFE_MESSAGE = "sms sender unavailable";
@Override
public void sendVerificationCode(String phone, String purpose, String code) {
throw new BusinessException(ErrorCode.SERVICE_UNAVAILABLE, SAFE_MESSAGE);
}
}

3
mydreamjob-server/src/main/java/com/mydreamjob/config/SecurityConfig.java

@ -115,7 +115,8 @@ public class SecurityConfig {
.requestMatchers(HttpMethod.GET, "/jobs/*/personalized-review-score").authenticated() .requestMatchers(HttpMethod.GET, "/jobs/*/personalized-review-score").authenticated()
// 岗位公开查询 // 岗位公开查询
.requestMatchers("/jobs/public/**").permitAll() .requestMatchers("/jobs/public/**").permitAll()
.requestMatchers("/jobs").permitAll() // 仅公开岗位列表为匿名可读;写操作必须命中下方的 /jobs/** → authenticated
.requestMatchers(HttpMethod.GET, "/jobs").permitAll()
// 岗位搜索与筛选(公开) // 岗位搜索与筛选(公开)
.requestMatchers(HttpMethod.GET, "/jobs/search").permitAll() .requestMatchers(HttpMethod.GET, "/jobs/search").permitAll()
.requestMatchers(HttpMethod.GET, "/jobs/filters").permitAll() .requestMatchers(HttpMethod.GET, "/jobs/filters").permitAll()

2
mydreamjob-server/src/main/java/com/mydreamjob/controller/AuthController.java

@ -38,7 +38,7 @@ public class AuthController {
private final SmsVerificationService smsVerificationService; private final SmsVerificationService smsVerificationService;
private final JwtTokenProvider jwtTokenProvider; private final JwtTokenProvider jwtTokenProvider;
@Operation(summary = "发送短信验证码(Mock)", description = "开发环境 Mock:验证码会打印到日志") @Operation(summary = "发送短信验证码", description = "开发/测试使用 Mock 发送实现;生产环境未接入短信网关时返回服务不可用。验证码不会写入日志。")
@PostMapping("/sms/send") @PostMapping("/sms/send")
public ResponseEntity<ApiResponse<Map<String, String>>> sendSmsCode( public ResponseEntity<ApiResponse<Map<String, String>>> sendSmsCode(
@RequestParam @NotBlank(message = "phone is required") @RequestParam @NotBlank(message = "phone is required")

27
mydreamjob-server/src/main/java/com/mydreamjob/job/JobController.java

@ -1,12 +1,15 @@
package com.mydreamjob.job; package com.mydreamjob.job;
import com.mydreamjob.common.api.ApiResponse; import com.mydreamjob.common.api.ApiResponse;
import com.mydreamjob.common.exception.BusinessException;
import com.mydreamjob.common.exception.ErrorCode;
import com.mydreamjob.company.Company; import com.mydreamjob.company.Company;
import com.mydreamjob.company.CompanyService; import com.mydreamjob.company.CompanyService;
import com.mydreamjob.job.dto.JobCreateRequest; import com.mydreamjob.job.dto.JobCreateRequest;
import com.mydreamjob.job.dto.JobSearchItem; import com.mydreamjob.job.dto.JobSearchItem;
import com.mydreamjob.job.dto.JobStatusRequest; import com.mydreamjob.job.dto.JobStatusRequest;
import com.mydreamjob.job.dto.JobUpdateRequest; import com.mydreamjob.job.dto.JobUpdateRequest;
import com.mydreamjob.security.CurrentUserAccessor;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletRequest;
@ -14,8 +17,6 @@ import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page; import org.springframework.data.domain.Page;
import org.springframework.http.ResponseEntity; 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 org.springframework.web.bind.annotation.*;
import java.util.HashMap; import java.util.HashMap;
@ -34,6 +35,7 @@ public class JobController {
private final JobService jobService; private final JobService jobService;
private final CompanyService companyService; private final CompanyService companyService;
private final CurrentUserAccessor currentUserAccessor;
// ── 企业端:创建岗位 ──────────────────────────────────────── // ── 企业端:创建岗位 ────────────────────────────────────────
@ -42,7 +44,7 @@ public class JobController {
public ResponseEntity<ApiResponse<Map<String, Object>>> createJob( public ResponseEntity<ApiResponse<Map<String, Object>>> createJob(
@Valid @RequestBody JobCreateRequest req, @Valid @RequestBody JobCreateRequest req,
HttpServletRequest request) { HttpServletRequest request) {
Long companyId = resolveCompanyId(request); Long companyId = resolveCompanyId();
Map<String, Object> view = jobService.toView(jobService.createJob(companyId, req)); Map<String, Object> view = jobService.toView(jobService.createJob(companyId, req));
return ResponseEntity.ok(ApiResponse.ok(view, getRequestId(request))); return ResponseEntity.ok(ApiResponse.ok(view, getRequestId(request)));
} }
@ -55,7 +57,7 @@ public class JobController {
@PathVariable Long jobId, @PathVariable Long jobId,
@Valid @RequestBody JobUpdateRequest req, @Valid @RequestBody JobUpdateRequest req,
HttpServletRequest request) { HttpServletRequest request) {
Long companyId = resolveCompanyId(request); Long companyId = resolveCompanyId();
Map<String, Object> view = jobService.toView(jobService.updateJob(companyId, jobId, req)); Map<String, Object> view = jobService.toView(jobService.updateJob(companyId, jobId, req));
return ResponseEntity.ok(ApiResponse.ok(view, getRequestId(request))); return ResponseEntity.ok(ApiResponse.ok(view, getRequestId(request)));
} }
@ -68,7 +70,7 @@ public class JobController {
@PathVariable Long jobId, @PathVariable Long jobId,
@Valid @RequestBody JobStatusRequest req, @Valid @RequestBody JobStatusRequest req,
HttpServletRequest request) { HttpServletRequest request) {
Long companyId = resolveCompanyId(request); Long companyId = resolveCompanyId();
Map<String, Object> view = jobService.toView(jobService.transitionStatus(companyId, jobId, req)); Map<String, Object> view = jobService.toView(jobService.transitionStatus(companyId, jobId, req));
return ResponseEntity.ok(ApiResponse.ok(view, getRequestId(request))); return ResponseEntity.ok(ApiResponse.ok(view, getRequestId(request)));
} }
@ -80,7 +82,7 @@ public class JobController {
public ResponseEntity<ApiResponse<Map<String, Object>>> getJob( public ResponseEntity<ApiResponse<Map<String, Object>>> getJob(
@PathVariable Long jobId, @PathVariable Long jobId,
HttpServletRequest request) { HttpServletRequest request) {
Long companyId = resolveCompanyId(request); Long companyId = resolveCompanyId();
// 验证归属后返回 // 验证归属后返回
jobService.assertOwnerOrThrow(companyId, jobId); jobService.assertOwnerOrThrow(companyId, jobId);
Map<String, Object> view = jobService.toView(jobService.getJob(jobId)); Map<String, Object> view = jobService.toView(jobService.getJob(jobId));
@ -163,17 +165,12 @@ public class JobController {
// ── 辅助方法 ──────────────────────────────────────────────── // ── 辅助方法 ────────────────────────────────────────────────
private Long resolveCompanyId(HttpServletRequest request) { private Long resolveCompanyId() {
Authentication auth = SecurityContextHolder.getContext().getAuthentication(); // 身份只来自 JWT/SecurityContext;非法主体统一为未登录语义(401),不抛 NumberFormatException
if (auth == null || !auth.isAuthenticated()) { Long userId = currentUserAccessor.requireUserId();
throw new com.mydreamjob.common.exception.BusinessException(
com.mydreamjob.common.exception.ErrorCode.UNAUTHORIZED);
}
Long userId = Long.valueOf(auth.getName());
Company company = companyService.findCompanyByOwnerUserId(userId); Company company = companyService.findCompanyByOwnerUserId(userId);
if (company == null) { if (company == null) {
throw new com.mydreamjob.common.exception.BusinessException( throw new BusinessException(ErrorCode.COMPANY_NOT_FOUND);
com.mydreamjob.common.exception.ErrorCode.COMPANY_NOT_FOUND);
} }
return company.getId(); return company.getId();
} }

82
mydreamjob-server/src/main/java/com/mydreamjob/security/CurrentUserAccessor.java

@ -0,0 +1,82 @@
package com.mydreamjob.security;
import org.springframework.security.authentication.AnonymousAuthenticationToken;
import org.springframework.security.authentication.InsufficientAuthenticationException;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
/**
* 安全关键路径的当前用户解析器。
*
* <p>身份<strong>只</strong>来自 JWT / SecurityContext,不接受任何请求参数传入的 userId。
* 所有非法主体一律转换为既有的“未登录”语义({@link InsufficientAuthenticationException}
* 由 {@code GlobalExceptionHandler} 映射为 HTTP 401),从而避免
* {@code NumberFormatException} 逃逸为 HTTP 500。</p>
*
* <p>明确拒绝:Authentication 为 null、未认证、{@link AnonymousAuthenticationToken}、
* 名称为空、名称为 anonymousUser、名称不是合法 Long、userId &lt;= 0。</p>
*
* <p>本类<strong>只</strong>负责身份解析,不承担角色校验与资源归属校验——
* 那两类判断仍由既有业务逻辑负责。</p>
*/
@Component
public class CurrentUserAccessor {
/** 固定安全文本:不回显任何非法 principal。 */
static final String UNAUTHENTICATED_MESSAGE = "authentication required";
private static final String ANONYMOUS_PRINCIPAL = "anonymousUser";
/** 解析当前请求的用户 ID(读取 SecurityContext)。 */
public Long requireUserId() {
return requireUserId(SecurityContextHolder.getContext().getAuthentication());
}
/**
* 解析用户 ID。任何非法输入都抛出未登录异常,绝不抛出 NumberFormatException。
*
* @param authentication 待校验的认证对象(可为 null)
* @return 大于 0 的已验证用户 ID
*/
public Long requireUserId(Authentication authentication) {
if (authentication == null
|| !authentication.isAuthenticated()
|| authentication instanceof AnonymousAuthenticationToken) {
throw unauthenticated();
}
String name = authentication.getName();
if (name == null || name.isBlank() || ANONYMOUS_PRINCIPAL.equals(name)) {
throw unauthenticated();
}
if (!isAsciiDigits(name)) {
throw unauthenticated();
}
long userId;
try {
userId = Long.parseLong(name);
} catch (NumberFormatException e) {
// 位数溢出等极端情况:同样按未登录处理,不回显原始值
throw unauthenticated();
}
if (userId <= 0) {
throw unauthenticated();
}
return userId;
}
/** 严格校验:仅接受 ASCII 数字,排除 "+42"、"-42"、空白与 Unicode 数字。 */
private boolean isAsciiDigits(String value) {
for (int i = 0; i < value.length(); i++) {
char c = value.charAt(i);
if (c < '0' || c > '9') {
return false;
}
}
return true;
}
private InsufficientAuthenticationException unauthenticated() {
return new InsufficientAuthenticationException(UNAUTHENTICATED_MESSAGE);
}
}

431
mydreamjob-server/src/test/java/com/mydreamjob/auth/AuthenticationBoundaryIntegrationTest.java

@ -0,0 +1,431 @@
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.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.job.JobStatus;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
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.beans.factory.annotation.Value;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.ValueOperations;
import org.springframework.http.MediaType;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
import javax.crypto.SecretKey;
import java.nio.charset.StandardCharsets;
import java.util.Comparator;
import java.util.Date;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.atomic.AtomicLong;
import java.util.stream.Stream;
import static org.assertj.core.api.Assertions.assertThat;
import static org.hamcrest.Matchers.containsString;
import static org.hamcrest.Matchers.not;
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.delete;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/**
* S01 认证与授权边界(真实 SecurityFilterChain + 真实 H2 Service)。
*
* <p>覆盖任务书第三节与第九节 C 组:
* 匿名写请求必须是 401(修复前为 500),越权请求不得产生业务写入,
* 公开 GET 语义保持不变,且失败响应不得回显 anonymousUser、非法 principal 或内部类名。</p>
*/
@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("integration-test")
class AuthenticationBoundaryIntegrationTest {
private static final AtomicLong PHONE_SEQ = new AtomicLong(7100);
private static final String BAD_PRINCIPAL = "INTERNAL_BAD_PRINCIPAL_SECRET";
private static final String VALID_JOB_BODY = """
{"title":"安全边界测试岗位","description":"desc","city":"杭州","employmentType":"FULL_TIME"}
""";
@MockBean
private RedisTemplate<String, Object> redisTemplate;
@Autowired
private MockMvc mockMvc;
@Autowired
private JwtTokenProvider jwtTokenProvider;
@Autowired
private UserRepository userRepository;
@Autowired
private CompanyRepository companyRepository;
@Autowired
private JobRepository jobRepository;
@Value("${app.jwt.secret}")
private String jwtSecret;
private Long companyUserId;
private Long otherCompanyUserId;
private Long studentUserId;
private Long adminUserId;
private Long companyId;
private Long otherCompanyId;
private Long jobId;
private ListAppender<ILoggingEvent> appender;
private Logger appLogger;
@BeforeEach
@SuppressWarnings("unchecked")
void setUp() {
ValueOperations<String, Object> ops = mock(ValueOperations.class);
when(redisTemplate.opsForValue()).thenReturn(ops);
when(redisTemplate.hasKey(anyString())).thenReturn(false);
companyUserId = createUser(User.Role.COMPANY);
otherCompanyUserId = createUser(User.Role.COMPANY);
studentUserId = createUser(User.Role.STUDENT);
adminUserId = createUser(User.Role.ADMIN);
companyId = createCompany(companyUserId, "安全边界甲公司");
otherCompanyId = createCompany(otherCompanyUserId, "安全边界乙公司");
jobId = jobRepository.save(Job.builder()
.companyId(companyId)
.title("边界夹具岗位")
.description("fixture")
.city("杭州")
.employmentType("FULL_TIME")
.status(JobStatus.DRAFT.name())
.build()).getId();
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();
}
// 本测试类与其他集成测试共用同一 JVM 内的 H2 命名库,必须清理自身夹具(FK 安全顺序),
// 否则残留的 companies 行会让后续 AuthIntegrationTest 的 userRepository.deleteAll() 触发 FK 约束错误。
List<Long> myCompanyIds = Stream.of(companyId, otherCompanyId)
.filter(Objects::nonNull).toList();
if (!myCompanyIds.isEmpty()) {
jobRepository.deleteAll(jobRepository.findAll().stream()
.filter(j -> myCompanyIds.contains(j.getCompanyId()))
.toList());
companyRepository.deleteAllById(myCompanyIds);
}
List<Long> myUserIds = Stream.of(companyUserId, otherCompanyUserId, studentUserId, adminUserId)
.filter(Objects::nonNull).toList();
if (!myUserIds.isEmpty()) {
userRepository.deleteAllById(myUserIds);
}
}
// ── A. 匿名写请求必须 401,不得 500,且无业务写入 ──────────────
@Test
void anonymousPostJobsMustReturn401InsteadOf500() throws Exception {
List<String> before = snapshotJobs();
mockMvc.perform(post("/jobs").contentType(MediaType.APPLICATION_JSON).content(VALID_JOB_BODY))
.andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.code").value(401))
.andExpect(content().string(not(containsString("anonymousUser"))))
.andExpect(content().string(not(containsString("NumberFormatException"))))
.andExpect(content().string(not(containsString("Exception"))))
.andExpect(content().string(not(containsString("com.mydreamjob"))))
.andExpect(content().string(not(containsString("java."))));
assertThat(snapshotJobs()).as("匿名创建不得写入任何岗位").isEqualTo(before);
}
@Test
void anonymousCannotCreateJob() throws Exception {
List<String> before = snapshotJobs();
mockMvc.perform(post("/jobs").contentType(MediaType.APPLICATION_JSON).content(VALID_JOB_BODY))
.andExpect(status().isUnauthorized());
assertThat(snapshotJobs())
.as("jobs 表完整内容不得变化(不只是行数)")
.isEqualTo(before);
}
@Test
void anonymousCannotUpdateJob() throws Exception {
List<String> before = snapshotJobs();
mockMvc.perform(put("/jobs/" + jobId)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"title\":\"被篡改\"}"))
.andExpect(status().isUnauthorized());
assertThat(snapshotJobs()).isEqualTo(before);
}
@Test
void anonymousCannotDeleteJob() throws Exception {
List<String> before = snapshotJobs();
mockMvc.perform(delete("/jobs/" + jobId))
.andExpect(status().isUnauthorized());
assertThat(snapshotJobs()).isEqualTo(before);
}
@Test
void anonymousCannotPublishOrCloseJob() throws Exception {
List<String> before = snapshotJobs();
mockMvc.perform(patch("/jobs/" + jobId + "/status")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"status\":\"PUBLISHED\"}"))
.andExpect(status().isUnauthorized());
assertThat(snapshotJobs()).isEqualTo(before);
assertThat(jobRepository.findById(jobId).orElseThrow().getStatus())
.isEqualTo(JobStatus.DRAFT.name());
}
@Test
void anonymousAuthenticationTokenMustBeRejectedBeforeControllerBusinessLogic() throws Exception {
List<String> before = snapshotJobs();
// 匿名请求会被 Spring Security 包装为 AnonymousAuthenticationToken(isAuthenticated() == true)。
// 修复前:命中 permitAll → 进入 Controller → Long.valueOf("anonymousUser") → 500。
mockMvc.perform(post("/jobs").contentType(MediaType.APPLICATION_JSON).content(VALID_JOB_BODY))
.andExpect(status().isUnauthorized())
.andExpect(content().string(not(containsString("anonymousUser"))));
assertThat(snapshotJobs()).isEqualTo(before);
}
@Test
void nonNumericPrincipalMustReturn401WithoutEcho() throws Exception {
List<String> before = snapshotJobs();
mockMvc.perform(post("/jobs")
.contentType(MediaType.APPLICATION_JSON)
.content(VALID_JOB_BODY)
.header("Authorization", "Bearer " + signedTokenWithSubject(BAD_PRINCIPAL)))
.andExpect(status().isUnauthorized())
.andExpect(content().string(not(containsString(BAD_PRINCIPAL))))
.andExpect(content().string(not(containsString("NumberFormatException"))))
.andExpect(content().string(not(containsString("java."))));
assertThat(capturedLogs())
.as("日志不得回显非法 principal")
.doesNotContain(BAD_PRINCIPAL);
assertThat(snapshotJobs()).isEqualTo(before);
}
// ── B. 公开 GET 语义保持不变 ───────────────────────────────────
@Test
void anonymousCanListPublishedJobs() throws Exception {
mockMvc.perform(get("/jobs"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(200))
.andExpect(jsonPath("$.data.items").isArray());
}
@Test
void anonymousCanReadPublicJobDetailIfExistingContractAllows() throws Exception {
Job job = jobRepository.findById(jobId).orElseThrow();
job.setStatus(JobStatus.PUBLISHED.name());
jobRepository.save(job);
mockMvc.perform(get("/jobs/public/" + jobId))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(200));
}
@Test
void anonymousCanSearchAndFilterJobs() throws Exception {
mockMvc.perform(get("/jobs/search")).andExpect(status().isOk());
mockMvc.perform(get("/jobs/filters")).andExpect(status().isOk());
}
// ── C. 已登录但非企业主体:不得创建岗位 ───────────────────────
@Test
void studentCannotCreateJob() throws Exception {
List<String> before = snapshotJobs();
// 既有语义:非 COMPANY 角色 → FORBIDDEN(2002) → HTTP 403
mockMvc.perform(post("/jobs")
.contentType(MediaType.APPLICATION_JSON)
.content(VALID_JOB_BODY)
.header("Authorization", token(studentUserId)))
.andExpect(status().isForbidden())
.andExpect(jsonPath("$.code").value(ErrorCode.FORBIDDEN.getCode()));
assertThat(snapshotJobs()).isEqualTo(before);
}
@Test
void adminCannotCreateJob() throws Exception {
List<String> before = snapshotJobs();
// 既有语义:ADMIN 也不是 COMPANY 角色 → FORBIDDEN(2002) → HTTP 403
mockMvc.perform(post("/jobs")
.contentType(MediaType.APPLICATION_JSON)
.content(VALID_JOB_BODY)
.header("Authorization", token(adminUserId)))
.andExpect(status().isForbidden())
.andExpect(jsonPath("$.code").value(ErrorCode.FORBIDDEN.getCode()));
assertThat(snapshotJobs()).isEqualTo(before);
}
// ── D. 企业主体:可创建;他企业不得修改 ────────────────────────
@Test
void companyCanCreateJob() throws Exception {
List<String> before = snapshotJobs();
mockMvc.perform(post("/jobs")
.contentType(MediaType.APPLICATION_JSON)
.content(VALID_JOB_BODY)
.header("Authorization", token(companyUserId)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(200))
.andExpect(jsonPath("$.data.status").value(JobStatus.DRAFT.name()));
List<String> after = snapshotJobs();
assertThat(after).hasSize(before.size() + 1);
assertThat(jobRepository.findAll())
.anySatisfy(j -> {
assertThat(j.getTitle()).isEqualTo("安全边界测试岗位");
assertThat(j.getCompanyId()).isEqualTo(companyOf(companyUserId));
});
}
@Test
void otherCompanyCannotModifyOwnedJob() throws Exception {
List<String> before = snapshotJobs();
// 既有语义:归属校验失败为 JOB_OWNER_MISMATCH(7002) → HTTP 400(本轮不改变错误码)
mockMvc.perform(put("/jobs/" + jobId)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"title\":\"他企业篡改\"}")
.header("Authorization", token(otherCompanyUserId)))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value(ErrorCode.JOB_OWNER_MISMATCH.getCode()));
assertThat(snapshotJobs()).isEqualTo(before);
}
@Test
void otherCompanyCannotPublishOwnedJob() throws Exception {
List<String> before = snapshotJobs();
mockMvc.perform(patch("/jobs/" + jobId + "/status")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"status\":\"PUBLISHED\"}")
.header("Authorization", token(otherCompanyUserId)))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value(ErrorCode.JOB_OWNER_MISMATCH.getCode()));
assertThat(snapshotJobs()).isEqualTo(before);
}
// ── 辅助 ──────────────────────────────────────────────────────
private List<String> snapshotJobs() {
return jobRepository.findAll().stream()
.sorted(Comparator.comparing(Job::getId))
.map(j -> String.join("|",
String.valueOf(j.getId()), String.valueOf(j.getCompanyId()),
String.valueOf(j.getTitle()), String.valueOf(j.getDescription()),
String.valueOf(j.getCity()), String.valueOf(j.getEmploymentType()),
String.valueOf(j.getStatus()),
String.valueOf(j.getSalaryMin()), String.valueOf(j.getSalaryMax()),
String.valueOf(j.getPublishedAt()), String.valueOf(j.getUpdatedAt())))
.toList();
}
private String capturedLogs() {
StringBuilder sb = new StringBuilder();
for (ILoggingEvent event : appender.list) {
sb.append(event.getLevel()).append(' ').append(event.getFormattedMessage()).append('\n');
if (event.getThrowableProxy() != null) {
sb.append(event.getThrowableProxy().getMessage()).append('\n');
}
}
return sb.toString();
}
private String token(Long userId) {
return "Bearer " + jwtTokenProvider.generateAccessToken(userId, "1", Map.of("roles", "STUDENT"));
}
private String signedTokenWithSubject(String subject) {
SecretKey key = Keys.hmacShaKeyFor(jwtSecret.getBytes(StandardCharsets.UTF_8));
long now = System.currentTimeMillis();
return Jwts.builder()
.subject(subject)
.issuedAt(new Date(now))
.expiration(new Date(now + 3_600_000L))
.signWith(key)
.compact();
}
private Long createUser(User.Role role) {
return userRepository.save(User.builder()
.phone("17" + String.format("%09d", PHONE_SEQ.incrementAndGet()))
.nickname("边界用户")
.role(role)
// 角色集合为 @ElementCollection,业务角色校验读取的是 roles 而非 role
.roles(new java.util.HashSet<>(java.util.Set.of(role)))
.active(true)
.verified(true)
.build()).getId();
}
private Long createCompany(Long ownerUserId, String name) {
return companyRepository.save(Company.builder()
.ownerUserId(ownerUserId)
.name(name)
.build()).getId();
}
private Long companyOf(Long ownerUserId) {
return companyRepository.findByOwnerUserId(ownerUserId).orElseThrow().getId();
}
}

79
mydreamjob-server/src/test/java/com/mydreamjob/auth/SmsEmptyPropertyStartupIntegrationTest.java

@ -0,0 +1,79 @@
package com.mydreamjob.auth;
import com.mydreamjob.auth.sms.SmsCodeSender;
import com.mydreamjob.auth.sms.UnavailableSmsCodeSender;
import com.mydreamjob.common.exception.BusinessException;
import com.mydreamjob.common.exception.ErrorCode;
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.data.redis.core.ValueOperations;
import org.springframework.test.context.ActiveProfiles;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
/**
* S01 定向整改 F3 核心回归:{@code app.sms.mock.enabled=} <strong>空值</strong>下的
* 全应用上下文启动。
*
* <p>整改前该取值会使两个互斥 {@code @ConditionalOnProperty} 都不匹配 → 0 个
* {@code SmsCodeSender} Bean → 应用以
* {@code APPLICATION FAILED TO START ... required a bean of type 'SmsCodeSender'}
* 直接启动失败。</p>
*
* <p>本测试以真实全量 Spring 上下文(H2 + Flyway)启动,证明:上下文启动成功、
* {@link SmsVerificationService} 可注入、恰好一个 {@link SmsCodeSender}、
* 类型为 {@link UnavailableSmsCodeSender},且调用发送时安全失败。</p>
*/
@SpringBootTest(properties = {"app.sms.mock.enabled="})
@ActiveProfiles("integration-test")
class SmsEmptyPropertyStartupIntegrationTest {
private static final String PHONE = "19900005678";
@MockBean
private RedisTemplate<String, Object> redisTemplate;
@Autowired
private ApplicationContext applicationContext;
@Autowired
private SmsVerificationService smsVerificationService;
@BeforeEach
@SuppressWarnings("unchecked")
void stubRedis() {
// 不触发锁定/限流分支,使流程到达发送器调用
when(redisTemplate.hasKey(anyString())).thenReturn(false);
when(redisTemplate.opsForValue()).thenReturn(mock(ValueOperations.class));
}
@Test
void emptyPropertyStartsContextWithExactlyOneUnavailableSender() {
assertThat(applicationContext.getBeanNamesForType(SmsCodeSender.class))
.as("空值必须恰好装配一个 SmsCodeSender")
.hasSize(1);
assertThat(applicationContext.getBean(SmsCodeSender.class))
.isInstanceOf(UnavailableSmsCodeSender.class);
assertThat(applicationContext)
.as("SmsVerificationService 必须可注入")
.isNotNull();
assertThat(smsVerificationService).isNotNull();
}
@Test
void emptyPropertySenderFailsSafely() {
assertThatThrownBy(() -> smsVerificationService.sendCode(PHONE, "login", null, null, null))
.isInstanceOf(BusinessException.class)
.satisfies(e -> assertThat(((BusinessException) e).getErrorCodeInt())
.isEqualTo(ErrorCode.SERVICE_UNAVAILABLE.getCode()));
}
}

230
mydreamjob-server/src/test/java/com/mydreamjob/auth/SmsSenderConfigurationTest.java

@ -0,0 +1,230 @@
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.auth.sms.MockSmsCodeSender;
import com.mydreamjob.auth.sms.SmsCodeSender;
import com.mydreamjob.auth.sms.SmsSenderConfiguration;
import com.mydreamjob.auth.sms.UnavailableSmsCodeSender;
import com.mydreamjob.common.exception.BusinessException;
import com.mydreamjob.common.exception.ErrorCode;
import org.junit.jupiter.api.Test;
import org.slf4j.LoggerFactory;
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/**
* S01 定向整改 F3:{@code app.sms.mock.enabled} 在
* true / false / 缺失 / 空值 / 仅空白 / 大写 / 非法值下都必须装配
* <strong>恰好一个</strong> {@link SmsCodeSender},且只有显式 true 才启用 Mock。
*
* <p>全部用例使用真实 {@link ApplicationContextRunner} 装配(不直接 new 配置类、
* 不测试字符串解析辅助函数、不使用 Mockito 验证)。</p>
*
* <p>{@link SenderConsumerProbe} 是<strong>消费者</strong>探针:它以构造参数方式依赖
* {@link SmsCodeSender},用于复现“构造注入时缺少 Bean”的原始缺陷场景;
* 它<strong>不生产</strong> any SmsCodeSender,因此不会掩盖生产 Bean 缺失。</p>
*/
class SmsSenderConfigurationTest {
/** 仅消费、不生产 SmsCodeSender 的探针。 */
static class SenderConsumerProbe {
final SmsCodeSender sender;
SenderConsumerProbe(SmsCodeSender sender) {
this.sender = sender;
}
}
private final ApplicationContextRunner runner = new ApplicationContextRunner()
.withUserConfiguration(SmsSenderConfiguration.class, SenderConsumerProbe.class);
private static final String ACC_PHONE = "19900009999";
private static final String ACC_CODE = "739251";
// ── 7 类取值行为 ────────────────────────────────────────────
@Test
void trueUsesMockSender() {
runner.withPropertyValues("app.sms.mock.enabled=true").run(context -> {
assertThat(context).hasNotFailed();
assertThat(context).hasSingleBean(SmsCodeSender.class);
assertThat(context.getBean(SmsCodeSender.class)).isInstanceOf(MockSmsCodeSender.class);
assertThat(context).doesNotHaveBean(UnavailableSmsCodeSender.class);
});
}
@Test
void falseUsesUnavailableSender() {
runner.withPropertyValues("app.sms.mock.enabled=false").run(context -> {
assertThat(context).hasNotFailed();
assertThat(context).hasSingleBean(SmsCodeSender.class);
assertThat(context.getBean(SmsCodeSender.class)).isInstanceOf(UnavailableSmsCodeSender.class);
assertThat(context).doesNotHaveBean(MockSmsCodeSender.class);
});
}
@Test
void missingPropertyUsesUnavailableSender() {
runner.run(context -> {
assertThat(context).hasNotFailed();
assertThat(context).hasSingleBean(SmsCodeSender.class);
assertThat(context.getBean(SmsCodeSender.class)).isInstanceOf(UnavailableSmsCodeSender.class);
});
}
@Test
void blankPropertyUsesUnavailableSender() {
// F3 核心回归:空值曾导致 0 个 Bean 与模糊 Spring 装配启动失败
runner.withPropertyValues("app.sms.mock.enabled=").run(context -> {
assertThat(context)
.as("空值必须仍能启动且装配一个可用实现")
.hasNotFailed();
assertThat(context).hasSingleBean(SmsCodeSender.class);
assertThat(context.getBean(SmsCodeSender.class)).isInstanceOf(UnavailableSmsCodeSender.class);
assertThat(context).doesNotHaveBean(MockSmsCodeSender.class);
});
}
@Test
void whitespacePropertyUsesUnavailableSender() {
runner.withPropertyValues("app.sms.mock.enabled= ").run(context -> {
assertThat(context).hasNotFailed();
assertThat(context).hasSingleBean(SmsCodeSender.class);
assertThat(context.getBean(SmsCodeSender.class)).isInstanceOf(UnavailableSmsCodeSender.class);
assertThat(context).doesNotHaveBean(MockSmsCodeSender.class);
});
}
@Test
void invalidPropertyNeverEnablesMockSender() {
for (String invalid : new String[]{"yes", "1", "on", "enabled", "TRUE1", "mock", "null"}) {
runner.withPropertyValues("app.sms.mock.enabled=" + invalid).run(context -> {
assertThat(context).as("非法值 %s 不应导致启动失败", invalid).hasNotFailed();
assertThat(context).hasSingleBean(SmsCodeSender.class);
assertThat(context.getBean(SmsCodeSender.class))
.as("非法值 %s 绝不允许启用 Mock", invalid)
.isInstanceOf(UnavailableSmsCodeSender.class);
assertThat(context).doesNotHaveBean(MockSmsCodeSender.class);
});
}
}
@Test
void uppercaseTrueBehaviorIsExplicit() {
// 明确语义:大小写不敏感 + 忽略两侧空白 → TRUE / True / " true " 视为启用 Mock
for (String accepted : new String[]{"TRUE", "True", " true ", "tRuE"}) {
runner.withPropertyValues("app.sms.mock.enabled=" + accepted).run(context -> {
assertThat(context).hasSingleBean(SmsCodeSender.class);
assertThat(context.getBean(SmsCodeSender.class))
.as("取值 [%s] 应视为显式启用 Mock", accepted)
.isInstanceOf(MockSmsCodeSender.class);
});
}
}
@Test
void everySupportedConfigurationCreatesExactlyOneSenderBean() {
String[] values = {"true", "false", "", " ", "TRUE", "yes", "1", "on", "null"};
for (String value : values) {
runner.withPropertyValues("app.sms.mock.enabled=" + value).run(context -> {
assertThat(context).as("取值 [%s]", value).hasNotFailed();
assertThat(context.getBeanNamesForType(SmsCodeSender.class))
.as("取值 [%s] 必须恰好一个 SmsCodeSender Bean", value)
.hasSize(1);
});
}
// 缺失属性
runner.run(context -> {
assertThat(context).hasNotFailed();
assertThat(context.getBeanNamesForType(SmsCodeSender.class))
.as("缺失属性时必须恰好一个 SmsCodeSender Bean")
.hasSize(1);
});
}
// ── 空值场景的完整证据链(消费者注入 + 安全失败 + 无敏感日志)────
@Test
void blankPropertyStillAllowsConstructorInjectionIntoConsumer() {
runner.withPropertyValues("app.sms.mock.enabled=").run(context -> {
assertThat(context).hasNotFailed();
// 复现原始缺陷场景:构造参数注入必须成功(曾因 0 个 Bean 失败)
SenderConsumerProbe probe = context.getBean(SenderConsumerProbe.class);
assertThat(probe.sender).isNotNull().isInstanceOf(UnavailableSmsCodeSender.class);
// 同一实例,证明容器内只有一个 Bean
assertThat(probe.sender).isSameAs(context.getBean(SmsCodeSender.class));
});
}
@Test
void blankPropertySenderFailsSafelyWithoutLeakingOrLogging() {
ListAppender<ILoggingEvent> appender = new ListAppender<>();
appender.start();
Logger logger = (Logger) LoggerFactory.getLogger("com.mydreamjob.auth.sms");
logger.addAppender(appender);
logger.setLevel(Level.TRACE);
try {
runner.withPropertyValues("app.sms.mock.enabled=").run(context -> {
SmsCodeSender sender = context.getBean(SmsCodeSender.class);
assertThatThrownBy(() -> sender.sendVerificationCode(ACC_PHONE, "login", ACC_CODE))
.isInstanceOf(BusinessException.class)
.satisfies(e -> {
BusinessException be = (BusinessException) e;
assertThat(be.getErrorCodeInt())
.isEqualTo(ErrorCode.SERVICE_UNAVAILABLE.getCode());
assertThat(be.getMessage())
.as("固定安全文本契约")
.isEqualTo("sms sender unavailable")
.doesNotContain(ACC_PHONE)
.doesNotContain(ACC_CODE)
.doesNotContainIgnoringCase("mock")
.doesNotContain("app.sms");
});
String logs = appender.list.stream()
.map(ILoggingEvent::getFormattedMessage)
.reduce("", (a, b) -> a + '\n' + b);
assertThat(logs)
.doesNotContain(ACC_CODE)
.doesNotContain(ACC_PHONE)
.doesNotContain("app.sms.mock.enabled");
});
} finally {
logger.detachAppender(appender);
appender.stop();
}
}
@Test
void unavailableSenderDoesNotReturnFalseSuccess() {
runner.withPropertyValues("app.sms.mock.enabled=false").run(context -> {
SmsCodeSender sender = context.getBean(SmsCodeSender.class);
assertThatThrownBy(() -> sender.sendVerificationCode(ACC_PHONE, "login", ACC_CODE))
.isInstanceOf(BusinessException.class)
.satisfies(e -> {
BusinessException be = (BusinessException) e;
assertThat(be.getErrorCodeInt()).isEqualTo(ErrorCode.SERVICE_UNAVAILABLE.getCode());
assertThat(be.getMessage())
.doesNotContain(ACC_PHONE)
.doesNotContain(ACC_CODE)
.doesNotContainIgnoringCase("mock");
});
});
}
@Test
void mockSenderLoadsOnlyWhenExplicitlyEnabled() {
runner.withPropertyValues("app.sms.mock.enabled=true").run(context -> {
assertThat(context).hasSingleBean(SmsCodeSender.class);
assertThat(context.getBean(SmsCodeSender.class)).isInstanceOf(MockSmsCodeSender.class);
assertThat(context).doesNotHaveBean(UnavailableSmsCodeSender.class);
});
}
}

115
mydreamjob-server/src/test/java/com/mydreamjob/auth/SmsUnavailableSenderRollbackIntegrationTest.java

@ -0,0 +1,115 @@
package com.mydreamjob.auth;
import com.mydreamjob.audit.AuditLog;
import com.mydreamjob.audit.AuditLogRepository;
import com.mydreamjob.auth.sms.SmsCodeSender;
import com.mydreamjob.auth.sms.UnavailableSmsCodeSender;
import com.mydreamjob.common.exception.BusinessException;
import com.mydreamjob.common.exception.ErrorCode;
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.data.redis.core.ValueOperations;
import org.springframework.test.context.ActiveProfiles;
import java.time.LocalDateTime;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
/**
* S01 定向整改 —— 补充回归:不可用发送实现下的事务回滚。
*
* <p>本测试使用<strong>真实 Spring 上下文 + 真实 H2 + Flyway + 真实
* {@link SmsVerificationService} 与真实 {@code @Transactional} 代理</strong>,
* 并在本测试类中把 {@code app.sms.mock.enabled} 覆盖为 {@code false},
* 因此容器装配的是 {@link UnavailableSmsCodeSender}。</p>
*
* <p>测试类本身<strong>不加</strong> {@code @Transactional},以保证
* {@code sendCode} 的事务独立提交/回滚,从而真实验证“发送失败不残留可用验证码”。</p>
*/
@SpringBootTest(properties = {"app.sms.mock.enabled=false"})
@ActiveProfiles("integration-test")
class SmsUnavailableSenderRollbackIntegrationTest {
private static final String PHONE = "19900001234";
private static final String PURPOSE = "login";
@MockBean
private RedisTemplate<String, Object> redisTemplate;
@Autowired
private ApplicationContext applicationContext;
@Autowired
private SmsVerificationService smsVerificationService;
@Autowired
private SmsVerificationCodeRepository codeRepository;
@Autowired
private AuditLogRepository auditLogRepository;
@BeforeEach
@SuppressWarnings("unchecked")
void setUp() {
ValueOperations<String, Object> ops = mock(ValueOperations.class);
when(redisTemplate.opsForValue()).thenReturn(ops);
when(redisTemplate.hasKey(anyString())).thenReturn(false);
}
@Test
void containerProvidesExactlyOneUnavailableSenderWhenMockDisabled() {
assertThat(applicationContext.getBeanNamesForType(SmsCodeSender.class))
.as("mock=false 时必须恰好一个 SmsCodeSender")
.hasSize(1);
assertThat(applicationContext.getBean(SmsCodeSender.class))
.isInstanceOf(UnavailableSmsCodeSender.class);
}
@Test
void unavailableSenderFailureLeavesNoVerificationCodeRow() {
// 清理本测试手机号的既有数据,保证断言只反映本次调用
List<SmsVerificationCode> pre = codeRepository.findByPhoneAndPurposeOrderByCreatedAtDesc(PHONE, PURPOSE);
if (!pre.isEmpty()) {
codeRepository.deleteAll(pre);
}
long beforeTotal = codeRepository.count();
LocalDateTime since = LocalDateTime.now().minusMinutes(1);
long auditBefore = auditLogRepository.findByCreatedAtAfter(since).stream()
.filter(a -> "SMS_SEND".equals(a.getAction()))
.count();
assertThatThrownBy(() -> smsVerificationService.sendCode(PHONE, PURPOSE, "rollback-test", null, null))
.isInstanceOf(BusinessException.class)
.satisfies(e -> assertThat(((BusinessException) e).getErrorCodeInt())
.isEqualTo(ErrorCode.SERVICE_UNAVAILABLE.getCode()));
// 事务已回滚:不得残留该手机号的任何验证码行(不只是数量相等)
assertThat(codeRepository.findByPhoneAndPurposeOrderByCreatedAtDesc(PHONE, PURPOSE))
.as("发送失败后不得残留可用验证码")
.isEmpty();
assertThat(codeRepository.count()).isEqualTo(beforeTotal);
// 失败不得产生“发送成功”审计
long auditAfter = auditLogRepository.findByCreatedAtAfter(since).stream()
.filter(a -> "SMS_SEND".equals(a.getAction()))
.count();
assertThat(auditAfter).isEqualTo(auditBefore);
}
@Test
void unavailableSenderFailureDoesNotReturnFalseSuccess() {
assertThatThrownBy(() -> smsVerificationService.sendCode(PHONE, PURPOSE, null, null, null))
.isInstanceOf(BusinessException.class)
.isNotInstanceOf(NumberFormatException.class);
}
}

332
mydreamjob-server/src/test/java/com/mydreamjob/auth/SmsVerificationSecurityTest.java

@ -0,0 +1,332 @@
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.audit.AuditService;
import com.mydreamjob.auth.sms.MockSmsCodeSender;
import com.mydreamjob.auth.sms.SmsCodeSender;
import com.mydreamjob.auth.sms.UnavailableSmsCodeSender;
import com.mydreamjob.common.exception.BusinessException;
import com.mydreamjob.common.exception.ErrorCode;
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.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.slf4j.LoggerFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.ValueOperations;
import org.springframework.test.util.ReflectionTestUtils;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.List;
import java.util.concurrent.TimeUnit;
import java.util.stream.Stream;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* S01 短信安全:验证码与手机号<strong>不得</strong>泄露到日志。
*
* <p>本测试不使用 Mockito 校验日志,而是挂载真实 Logback {@link ListAppender}
* 捕获实际日志事件后做字符串断言。</p>
*
* <p>生产语义不动:长度、有效期、最大尝试次数、锁定时间、一次性消费保持不变。</p>
*/
@ExtendWith(MockitoExtension.class)
class SmsVerificationSecurityTest {
/** 唯一敏感标记:手机号 */
private static final String SENSITIVE_PHONE = "19900009999";
private static final String PURPOSE = "login";
private static final int EXPECTED_CODE_LENGTH = 6;
private static final int EXPECTED_EXPIRE_SECONDS = 300;
private static final int EXPECTED_MAX_ATTEMPTS = 5;
private static final int EXPECTED_LOCKOUT_SECONDS = 300;
@Mock
private SmsVerificationCodeRepository codeRepository;
@Mock
private RedisTemplate<String, Object> redisTemplate;
@Mock
private ValueOperations<String, Object> valueOperations;
@Mock
private AuditService auditService;
@Mock
private SmsCodeSender smsCodeSender;
@InjectMocks
private SmsVerificationService service;
private Logger rootLogger;
private Logger appLogger;
private ListAppender<ILoggingEvent> appender;
private Level originalRootLevel;
private Level originalAppLevel;
@BeforeEach
void setUp() {
ReflectionTestUtils.setField(service, "codeLength", EXPECTED_CODE_LENGTH);
ReflectionTestUtils.setField(service, "expireSeconds", EXPECTED_EXPIRE_SECONDS);
ReflectionTestUtils.setField(service, "maxAttempts", EXPECTED_MAX_ATTEMPTS);
ReflectionTestUtils.setField(service, "lockoutSeconds", EXPECTED_LOCKOUT_SECONDS);
lenient().when(redisTemplate.opsForValue()).thenReturn(valueOperations);
// ── 挂载真实 Logback 捕获器(root + 应用包,覆盖 INFO/WARN/ERROR/DEBUG)──
rootLogger = (Logger) LoggerFactory.getLogger(Logger.ROOT_LOGGER_NAME);
appLogger = (Logger) LoggerFactory.getLogger("com.mydreamjob");
originalRootLevel = rootLogger.getLevel();
originalAppLevel = appLogger.getLevel();
rootLogger.setLevel(Level.TRACE);
appLogger.setLevel(Level.TRACE);
appender = new ListAppender<>();
appender.start();
rootLogger.addAppender(appender);
}
@AfterEach
void tearDown() {
rootLogger.detachAppender(appender);
appender.stop();
rootLogger.setLevel(originalRootLevel);
appLogger.setLevel(originalAppLevel);
}
private void stubSendAllowed() {
when(redisTemplate.hasKey(anyString())).thenReturn(false);
when(codeRepository.save(any(SmsVerificationCode.class)))
.thenAnswer(invocation -> invocation.getArgument(0));
}
private String capturedLogText() {
StringBuilder sb = new StringBuilder();
for (ILoggingEvent event : appender.list) {
sb.append(event.getLevel()).append(' ')
.append(event.getFormattedMessage()).append('\n');
if (event.getThrowableProxy() != null) {
sb.append(event.getThrowableProxy().getMessage()).append('\n');
}
}
return sb.toString();
}
// ── 核心:生产语义下不得泄露验证码/手机号 ──────────────────────
@Test
void productionProfileMustNotLogVerificationCodeOrFullPhone() {
stubSendAllowed();
ArgumentCaptor<SmsVerificationCode> captor = ArgumentCaptor.forClass(SmsVerificationCode.class);
service.sendCode(SENSITIVE_PHONE, PURPOSE, "req-1", "127.0.0.1", "junit");
verify(codeRepository).save(captor.capture());
String generatedCode = captor.getValue().getCode();
assertThat(generatedCode).hasSize(EXPECTED_CODE_LENGTH);
String logs = capturedLogText();
assertThat(logs)
.as("日志不得包含验证码明文")
.doesNotContain(generatedCode);
assertThat(logs)
.as("日志不得包含完整手机号")
.doesNotContain(SENSITIVE_PHONE);
assertThat(logs)
.as("日志不得包含完整验证码的任何形式")
.doesNotContain("code=" + generatedCode);
}
@Test
void smsLogsNeverContainCodePhoneTokenPasswordOrSecret() {
stubSendAllowed();
ArgumentCaptor<SmsVerificationCode> captor = ArgumentCaptor.forClass(SmsVerificationCode.class);
service.sendCode(SENSITIVE_PHONE, PURPOSE, "req-2", "127.0.0.1", "junit");
// 同时触发锁定与限流分支,覆盖 WARN 级日志
when(redisTemplate.hasKey(eq("sms:lockout:" + SENSITIVE_PHONE))).thenReturn(true);
assertThatThrownBy(() -> service.sendCode(SENSITIVE_PHONE, PURPOSE, "req-3", "127.0.0.1", "junit"))
.isInstanceOf(BusinessException.class);
verify(codeRepository).save(captor.capture());
String generatedCode = captor.getValue().getCode();
String logs = capturedLogText();
assertThat(logs)
.doesNotContain(generatedCode)
.doesNotContain(SENSITIVE_PHONE)
.doesNotContainIgnoringCase("password")
.doesNotContainIgnoringCase("secret");
}
@Test
void auditDetailsMustNotContainFullPhone() {
stubSendAllowed();
service.sendCode(SENSITIVE_PHONE, PURPOSE, "req-4", "127.0.0.1", "junit");
ArgumentCaptor<String> details = ArgumentCaptor.forClass(String.class);
verify(auditService).log(any(), eq("SMS_SEND"), eq("PHONE"), any(), details.capture(),
any(), any(), any());
assertThat(details.getValue())
.as("审计明细不得记录完整手机号")
.doesNotContain(SENSITIVE_PHONE);
}
// ── 生产语义回归:长度/有效期/尝试次数/锁定/一次性消费 ──────────
@Test
void verificationExpiryRemainsUnchanged() {
stubSendAllowed();
ArgumentCaptor<SmsVerificationCode> captor = ArgumentCaptor.forClass(SmsVerificationCode.class);
LocalDateTime before = LocalDateTime.now();
service.sendCode(SENSITIVE_PHONE, PURPOSE, null, null, null);
verify(codeRepository).save(captor.capture());
SmsVerificationCode saved = captor.getValue();
assertThat(saved.getCode()).hasSize(EXPECTED_CODE_LENGTH);
assertThat(saved.getExpiredAt())
.isAfter(before.plusSeconds(EXPECTED_EXPIRE_SECONDS - 5))
.isBefore(before.plusSeconds(EXPECTED_EXPIRE_SECONDS + 5));
assertThat(saved.getMaxAttempts()).isEqualTo(EXPECTED_MAX_ATTEMPTS);
assertThat(saved.getUsed()).isFalse();
assertThat(saved.getAttempts()).isZero();
}
@Test
void verificationAttemptLimitRemainsUnchanged() {
SmsVerificationCode stored = code("111111", 0, false);
when(redisTemplate.hasKey(anyString())).thenReturn(false);
when(codeRepository.streamLatestUnusedByPhoneAndPurpose(SENSITIVE_PHONE, PURPOSE))
.thenReturn(Stream.of(stored));
ArgumentCaptor<SmsVerificationCode> captor = ArgumentCaptor.forClass(SmsVerificationCode.class);
assertThatThrownBy(() -> service.verifyCode(SENSITIVE_PHONE, "999999", PURPOSE))
.isInstanceOf(BusinessException.class)
.satisfies(e -> assertThat(((BusinessException) e).getErrorCodeInt())
.isEqualTo(ErrorCode.SMS_CODE_INVALID.getCode()));
verify(codeRepository).save(captor.capture());
assertThat(captor.getValue().getAttempts()).isEqualTo(1);
assertThat(stored.getUsed()).isFalse();
}
@Test
void verificationLockoutRemainsUnchanged() {
SmsVerificationCode stored = code("111111", EXPECTED_MAX_ATTEMPTS, false);
when(redisTemplate.hasKey(anyString())).thenReturn(false);
when(codeRepository.streamLatestUnusedByPhoneAndPurpose(SENSITIVE_PHONE, PURPOSE))
.thenReturn(Stream.of(stored));
assertThatThrownBy(() -> service.verifyCode(SENSITIVE_PHONE, "111111", PURPOSE))
.isInstanceOf(BusinessException.class)
.satisfies(e -> assertThat(((BusinessException) e).getErrorCodeInt())
.isEqualTo(ErrorCode.SMS_CODE_ATTEMPTS_EXCEEDED.getCode()));
verify(valueOperations).set("sms:lockout:" + SENSITIVE_PHONE, "1",
(long) EXPECTED_LOCKOUT_SECONDS, TimeUnit.SECONDS);
}
@Test
void successfulVerificationStillConsumesCodeOnce() {
SmsVerificationCode stored = code("654321", 0, false);
when(redisTemplate.hasKey(anyString())).thenReturn(false);
when(codeRepository.streamLatestUnusedByPhoneAndPurpose(SENSITIVE_PHONE, PURPOSE))
.thenReturn(Stream.of(stored));
assertThat(service.verifyCode(SENSITIVE_PHONE, "654321", PURPOSE)).isTrue();
assertThat(stored.getUsed()).isTrue();
verify(codeRepository).save(stored);
// 重放:已使用记录不再被查询命中
when(codeRepository.streamLatestUnusedByPhoneAndPurpose(SENSITIVE_PHONE, PURPOSE))
.thenReturn(Stream.empty());
assertThatThrownBy(() -> service.verifyCode(SENSITIVE_PHONE, "654321", PURPOSE))
.isInstanceOf(BusinessException.class);
}
@Test
void expiredCodeStillRejected() {
SmsVerificationCode expired = SmsVerificationCode.builder()
.phone(SENSITIVE_PHONE).code("222222").purpose(PURPOSE)
.expiredAt(LocalDateTime.now().minus(Duration.ofSeconds(1)))
.attempts(0).maxAttempts(EXPECTED_MAX_ATTEMPTS).used(false)
.createdAt(LocalDateTime.now().minus(Duration.ofMinutes(6)))
.build();
when(redisTemplate.hasKey(anyString())).thenReturn(false);
when(codeRepository.streamLatestUnusedByPhoneAndPurpose(SENSITIVE_PHONE, PURPOSE))
.thenReturn(Stream.of(expired));
assertThatThrownBy(() -> service.verifyCode(SENSITIVE_PHONE, "222222", PURPOSE))
.isInstanceOf(BusinessException.class)
.satisfies(e -> assertThat(((BusinessException) e).getErrorCodeInt())
.isEqualTo(ErrorCode.SMS_CODE_EXPIRED.getCode()));
verify(codeRepository, never()).save(any(SmsVerificationCode.class));
}
// ── 发送实现安全边界 ───────────────────────────────────────────
@Test
void mockSenderMustNotLogCodeOrPhone() {
ListAppender<ILoggingEvent> senderAppender = new ListAppender<>();
senderAppender.start();
Logger senderLogger = (Logger) LoggerFactory.getLogger(MockSmsCodeSender.class);
senderLogger.addAppender(senderAppender);
senderLogger.setLevel(Level.TRACE);
try {
new MockSmsCodeSender().sendVerificationCode(SENSITIVE_PHONE, PURPOSE, "739251");
String logs = senderAppender.list.stream()
.map(ILoggingEvent::getFormattedMessage)
.reduce("", (a, b) -> a + '\n' + b);
assertThat(logs).doesNotContain("739251").doesNotContain(SENSITIVE_PHONE);
} finally {
senderLogger.detachAppender(senderAppender);
senderAppender.stop();
}
}
@Test
void unavailableSenderFailsSafelyWithoutLeaking() {
assertThatThrownBy(() ->
new UnavailableSmsCodeSender().sendVerificationCode(SENSITIVE_PHONE, PURPOSE, "739251"))
.isInstanceOf(BusinessException.class)
.satisfies(e -> {
BusinessException be = (BusinessException) e;
assertThat(be.getErrorCodeInt()).isEqualTo(ErrorCode.SERVICE_UNAVAILABLE.getCode());
assertThat(be.getMessage())
.doesNotContain(SENSITIVE_PHONE)
.doesNotContain("739251")
.doesNotContainIgnoringCase("mock")
.doesNotContain("app.sms");
});
}
private SmsVerificationCode code(String value, int attempts, boolean used) {
return SmsVerificationCode.builder()
.phone(SENSITIVE_PHONE).code(value).purpose(PURPOSE)
.expiredAt(LocalDateTime.now().plusSeconds(EXPECTED_EXPIRE_SECONDS))
.attempts(attempts).maxAttempts(EXPECTED_MAX_ATTEMPTS).used(used)
.createdAt(LocalDateTime.now())
.build();
}
}

4
mydreamjob-server/src/test/java/com/mydreamjob/auth/SmsVerificationServiceTest.java

@ -1,6 +1,7 @@
package com.mydreamjob.auth; package com.mydreamjob.auth;
import com.mydreamjob.audit.AuditService; import com.mydreamjob.audit.AuditService;
import com.mydreamjob.auth.sms.SmsCodeSender;
import com.mydreamjob.common.exception.BusinessException; import com.mydreamjob.common.exception.BusinessException;
import com.mydreamjob.common.exception.ErrorCode; import com.mydreamjob.common.exception.ErrorCode;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
@ -35,6 +36,9 @@ class SmsVerificationServiceTest {
@Mock @Mock
private AuditService auditService; private AuditService auditService;
@Mock
private SmsCodeSender smsCodeSender;
@InjectMocks @InjectMocks
private SmsVerificationService smsVerificationService; private SmsVerificationService smsVerificationService;

152
mydreamjob-server/src/test/java/com/mydreamjob/security/CurrentUserAccessorTest.java

@ -0,0 +1,152 @@
package com.mydreamjob.security;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.springframework.security.authentication.AnonymousAuthenticationToken;
import org.springframework.security.authentication.InsufficientAuthenticationException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.authority.AuthorityUtils;
import org.springframework.security.core.context.SecurityContextHolder;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/**
* S01 当前用户解析安全边界。
*
* <p>覆盖任务书要求的 8 类输入,并额外覆盖 Long 溢出与“已认证但名称为 anonymousUser”的绕过尝试。
* 所有非法输入必须转换为既有“未登录”语义({@link InsufficientAuthenticationException},
* 由全局异常处理器映射为 401),且<strong>不回显</strong>非法 principal。</p>
*/
class CurrentUserAccessorTest {
private static final String BAD_PRINCIPAL = "INTERNAL_BAD_PRINCIPAL_SECRET";
private final CurrentUserAccessor accessor = new CurrentUserAccessor();
@AfterEach
void clearContext() {
SecurityContextHolder.clearContext();
}
@Test
void nullAuthenticationIsUnauthorized() {
assertUnauthorized(() -> accessor.requireUserId((Authentication) null));
}
@Test
void unauthenticatedAuthenticationIsUnauthorized() {
// 双参构造 → isAuthenticated() == false
Authentication unauthenticated = new UsernamePasswordAuthenticationToken("42", null);
assertThat(unauthenticated.isAuthenticated()).isFalse();
assertUnauthorized(() -> accessor.requireUserId(unauthenticated));
}
@Test
void anonymousAuthenticationTokenIsUnauthorized() {
AnonymousAuthenticationToken anonymous = new AnonymousAuthenticationToken(
"anonymous-key", "anonymousUser",
AuthorityUtils.createAuthorityList("ROLE_ANONYMOUS"));
// 关键前提:Spring 的匿名令牌 isAuthenticated() 为 true,
// 仅检查 isAuthenticated() 会把匿名当成已登录用户。
assertThat(anonymous.isAuthenticated()).isTrue();
assertUnauthorized(() -> accessor.requireUserId(anonymous));
}
@Test
void anonymousUserNameIsUnauthorized() {
Authentication authenticatedButAnonymousName =
UsernamePasswordAuthenticationToken.authenticated("anonymousUser", null, List.of());
assertUnauthorized(() -> accessor.requireUserId(authenticatedButAnonymousName));
}
@Test
void blankPrincipalIsUnauthorized() {
assertUnauthorized(() -> accessor.requireUserId(
UsernamePasswordAuthenticationToken.authenticated(" ", null, List.of())));
}
@Test
void nonNumericPrincipalIsUnauthorizedWithoutEcho() {
Authentication bad = UsernamePasswordAuthenticationToken.authenticated(BAD_PRINCIPAL, null, List.of());
assertThatThrownBy(() -> accessor.requireUserId(bad))
.isInstanceOf(InsufficientAuthenticationException.class)
.satisfies(e -> {
assertThat(e.getMessage()).doesNotContain(BAD_PRINCIPAL);
assertThat(String.valueOf(e)).doesNotContain(BAD_PRINCIPAL);
// 异常及其堆栈不得携带非法 principal 原文
assertThat(java.util.Arrays.toString(e.getStackTrace()))
.doesNotContain(BAD_PRINCIPAL);
});
}
@Test
void nonPositiveUserIdIsUnauthorized() {
assertUnauthorized(() -> accessor.requireUserId(
UsernamePasswordAuthenticationToken.authenticated("0", null, List.of())));
assertUnauthorized(() -> accessor.requireUserId(
UsernamePasswordAuthenticationToken.authenticated("-1", null, List.of())));
}
@Test
void validNumericPrincipalReturnsUserId() {
assertThat(accessor.requireUserId(
UsernamePasswordAuthenticationToken.authenticated("42", null, List.of())))
.isEqualTo(42L);
assertThat(accessor.requireUserId(
UsernamePasswordAuthenticationToken.authenticated(
String.valueOf(Long.MAX_VALUE), null, List.of())))
.isEqualTo(Long.MAX_VALUE);
}
// ── 额外边界:不得让 NumberFormatException 逃逸 ─────────────────
@Test
void numericOverflowIsUnauthorizedNotNumberFormatException() {
Authentication overflow = UsernamePasswordAuthenticationToken.authenticated(
"99999999999999999999", null, List.of());
assertThatThrownBy(() -> accessor.requireUserId(overflow))
.isInstanceOf(InsufficientAuthenticationException.class)
.isNotInstanceOf(NumberFormatException.class);
}
@Test
void plusSignAndWhitespacePrincipalsAreUnauthorized() {
assertUnauthorized(() -> accessor.requireUserId(
UsernamePasswordAuthenticationToken.authenticated("+42", null, List.of())));
assertUnauthorized(() -> accessor.requireUserId(
UsernamePasswordAuthenticationToken.authenticated(" 42", null, List.of())));
assertUnauthorized(() -> accessor.requireUserId(
UsernamePasswordAuthenticationToken.authenticated("42 ", null, List.of())));
}
// ── SecurityContext 版本 ────────────────────────────────────────
@Test
void requireUserIdReadsFromSecurityContext() {
assertUnauthorized(() -> accessor.requireUserId());
SecurityContextHolder.getContext().setAuthentication(
UsernamePasswordAuthenticationToken.authenticated("7", null, List.of()));
assertThat(accessor.requireUserId()).isEqualTo(7L);
SecurityContextHolder.getContext().setAuthentication(
new AnonymousAuthenticationToken("k", "anonymousUser",
AuthorityUtils.createAuthorityList("ROLE_ANONYMOUS")));
assertUnauthorized(() -> accessor.requireUserId());
}
private void assertUnauthorized(Runnable action) {
assertThatThrownBy(action::run)
.isInstanceOf(InsufficientAuthenticationException.class);
}
}
Loading…
Cancel
Save