forked from baskbuddy/zhangshun
37 changed files with 1887 additions and 31 deletions
@ -0,0 +1,106 @@ |
|||||
|
# Phase A 企业注册与入驻基础闭环实施报告 |
||||
|
|
||||
|
日期:2026-09-22 |
||||
|
范围:企业专用注册、`company-register` 验证码、事务创建、入驻聚合查询 |
||||
|
基线:`master` / `00153aa5d92d13e66d6cdc42ecd1fe903d6e666` |
||||
|
|
||||
|
## 1. 结论与边界 |
||||
|
|
||||
|
本阶段实现了企业注册和企业入驻状态聚合,不进入 Phase B 已认证企业业务门禁,不进入 Phase C 管理员用户与角色管理,不修改 `users.version`、`user_management_actions`、岗位/简历/Offer/面试等业务门禁。 |
||||
|
|
||||
|
当前未暂存、未提交、未推送;V1–V46 未修改;未新增 V47;没有修改生产数据库的迁移脚本。 |
||||
|
|
||||
|
## 2. 权威库存 |
||||
|
|
||||
|
编码前核对了 `AuthController`/`AuthService`、`SmsVerificationService`/`SmsVerificationCodeRepository`、`PasswordPolicy`/`PasswordAuthenticationService`、`AuthSessionService.createSession`、注销手机号墓碑、`User`/`UserRepository`/`user_roles`、`Company`/`CompanyRepository`/`CompanyService`、企业认证服务与状态、`ErrorCode`/`ErrorHttpStatusRegistry`、`SecurityConfig` 以及 OpenAPI 基线。 |
||||
|
|
||||
|
实际短信用途以源码为准,保留 `login`、`password-set`、`password-reset`,新增并白名单化 `company-register`;任意其他非空用途均拒绝。OpenAPI 基线为 180 paths / 218 operations,本阶段变更为 182 paths / 220 operations。 |
||||
|
|
||||
|
## 3. 修改文件 |
||||
|
|
||||
|
生产代码新增: |
||||
|
|
||||
|
- `auth/company/CompanyRegistrationProperties.java` |
||||
|
- `auth/company/CompanyRegistrationRequest.java` |
||||
|
- `auth/company/CompanyRegistrationResponse.java` |
||||
|
- `auth/company/CompanyRegistrationService.java` |
||||
|
- `company/CompanyOnboardingResponse.java` |
||||
|
- `company/CompanyOnboardingService.java` |
||||
|
|
||||
|
生产代码修改:`AuthController`、`CompanyController`、`SecurityConfig`、`application.yml`、`SmsVerificationService`、`SmsVerificationCodeRepository`、`AuditService`、`ErrorCode`、`ErrorHttpStatusRegistry`。 |
||||
|
|
||||
|
测试新增/调整为 Phase A 行为锁定、既有短信/映射/OpenAPI 计数同步;未修改 V1–V46 或 `pom.xml`。 |
||||
|
|
||||
|
## 4. 企业注册契约 |
||||
|
|
||||
|
新增 `POST /auth/company/register`,使用强类型请求 DTO 和 Jakarta Validation。客户端只能提交手机号、验证码、密码及企业资料,不能提交 `userId`、`role`、`verified` 或 `passwordHash`。成功响应包含 `userId`、`companyId`、`role=COMPANY`、`onboardingStatus=UNVERIFIED`、现有格式的 Access/Refresh Token 和 `expiresIn`,不回显完整手机号、密码、验证码或密码摘要。 |
||||
|
|
||||
|
新增 `GET /companies/me/onboarding`,仅认证用户可访问,响应只包含企业安全摘要、聚合状态、认证申请安全摘要、`missingFields` 与 `allowedActions`,不返回 `materialsJson`、`decisionReason`、`reviewerId`、存储路径或其他敏感企业数据。未新增重复的 verifications 接口。 |
||||
|
|
||||
|
## 5. 事务边界 |
||||
|
|
||||
|
`CompanyRegistrationService.register` 在一个数据库事务中完成:功能开关、短信校验/消费、注销墓碑、手机号占用检查、BCrypt 密码、用户、`user_roles`、企业资料、登录会话和固定脱敏审计事件。用户保存并 flush 后再创建企业和会话;注册失败不会向客户端返回未提交 Token。数据库唯一约束是并发最终防线,`DataIntegrityViolationException` 转为统一冲突业务错误。 |
||||
|
|
||||
|
审计写入使用 `AuditService.logRequired` 的 `PROPAGATION_MANDATORY` + `saveAndFlush`,因此审计失败会使注册事务回滚。Redis 限流键不属于数据库事务,报告和测试没有虚假声称 Redis 自动回滚。 |
||||
|
|
||||
|
## 6. 验证码原子消费 |
||||
|
|
||||
|
`SmsVerificationCodeRepository.consumeIfValid` 使用条件更新,同时约束 `id`、`used=false`、未过期和 `attempts < maxAttempts`。只有受影响行数恰为 1 才算消费成功,0 行统一按验证码无效处理。错误验证码仍递增尝试次数并保留锁定语义;用途在查询前经过固定白名单校验。企业注册事务回滚时,数据库中的 `used` 更新随事务回滚。 |
||||
|
|
||||
|
修复前行为是“查询 → 修改实体 `used` → `save`”,受控变异到该实现时,`SmsVerificationAtomicConsumptionTest` 为 4 run / 2 failures(条件更新未调用、0 行竞争未失败);恢复条件更新后为 4 / 0 / 0 / 0。该变异证明测试不是空转。 |
||||
|
|
||||
|
## 7. 入驻状态推导 |
||||
|
|
||||
|
- `companies.verified=true` → `VERIFIED`; |
||||
|
- 未认证且无申请 → `UNVERIFIED`; |
||||
|
- 最新 `PENDING` 或 `RESUBMITTED` → `PENDING`; |
||||
|
- 最新 `REJECTED` → `REJECTED`; |
||||
|
- 未认证但最新 `APPROVED` 或未知状态 → 失败关闭并返回专用一致性错误。 |
||||
|
|
||||
|
`missingFields` 从资料实际内容派生,没有新增重复状态字段;`allowedActions` 随聚合状态返回。 |
||||
|
|
||||
|
## 8. 错误码与安全配置 |
||||
|
|
||||
|
新增唯一数字码:`COMPANY_REGISTRATION_DISABLED=10301`、`COMPANY_REGISTRATION_CONFLICT=10302`、`COMPANY_ONBOARDING_INCONSISTENT_STATE=10303`、`COMPANY_REGISTRATION_DEPENDENCY_UNAVAILABLE=10304`。映射统一进入 `ErrorHttpStatusRegistry`:关闭/依赖不可用为 503,冲突/不一致为 409。 |
||||
|
|
||||
|
匿名放行仅新增 `POST /auth/company/register`;`GET /companies/me/onboarding` 保持认证,学生、管理员和匿名均不获得企业入驻数据。日志、审计和响应不记录密码、摘要、验证码、Token、SQL、手机号或请求 DTO。 |
||||
|
|
||||
|
## 9. 测试矩阵 |
||||
|
|
||||
|
新增了企业注册 service/integration/concurrency、入驻 service/controller、验证码原子消费、用途白名单、事务回滚、敏感数据、功能开关等测试类;并同步了既有短信安全、公司 Controller 和异常映射测试。 |
||||
|
|
||||
|
- Phase A 定向测试:全部通过;两项阻断整改后的完整定向集 **28/28**; |
||||
|
- 真实 MySQL/Testcontainers 并发:1/1,通过同验证码双线程仅一个成功; |
||||
|
- `mvn test`:**2865 / Failures 0 / Errors 0 / Skipped 0**; |
||||
|
- `mvn clean verify`:**2865 / Failures 0 / Errors 0 / Skipped 0**; |
||||
|
- OpenAPI:`python ..\\docs\\verify_openapi.py` → **182 paths / 220 operations,All checks passed**。 |
||||
|
|
||||
|
全量验证期间出现的 Hikari 重连和 MySQL 8.4/Flyway 兼容性 warning 均未导致失败;一次与编译并发运行的错误结果未计入,随后单独顺序运行得到上述完整通过结果。 |
||||
|
|
||||
|
测试/脚本自身问题:早期黑盒驱动曾把短信发送参数误放在 JSON body(实际接口是 query 参数),且临时 JWT 密钥长度不足 32 字符;两项均在产品代码之外修正。重复运行时还触发了短信限流,随后销毁临时数据库并以全新隔离容器重跑,得到本节所列成功结果。未放宽产品断言。 |
||||
|
|
||||
|
## 10. 真实 MySQL/Redis/RabbitMQ 黑盒 |
||||
|
|
||||
|
使用临时隔离容器(MySQL 8.4、Redis 7.4、RabbitMQ 3.13)和最新 JAR 完成真实 HTTP 验收。三中间件均真实健康,应用 `/api/v1/health` 为 200/UP。 |
||||
|
|
||||
|
验证结果:匿名 onboarding 为 401;无有效验证码注册被拒;发送 `company-register` 后注册 200;密码登录 200;`/auth/me` 200;带注册 Access Token 的 onboarding 200。数据库逐表核对为:目标手机号 `users=1`、`companies=1`、`user_roles=1` 且角色仅 `COMPANY`、`companies.verified=0`、验证码 `used=1`。密码登录产生第二个合法会话,属于预期行为。临时容器、临时 JAR 进程、临时端口和运行日志均已清理,未触碰既有 backend-infra 卷或历史数据。 |
||||
|
|
||||
|
## 11. 敏感信息扫描 |
||||
|
|
||||
|
扫描新增生产代码、测试代码、OpenAPI、验证脚本相关输出和本报告:未发现真实手机号、明文密码、BCrypt 摘要、JWT/Bearer、Refresh Token、数据库/Redis/RabbitMQ 密码、私钥、AWS key 或连接串。黑盒使用的临时凭据只存在于进程环境和临时容器,测试结束后容器和日志已删除;报告不记录其值。 |
||||
|
|
||||
|
## 12. 数据与兼容性 |
||||
|
|
||||
|
未新增数据库迁移;V1–V46 逐项保持不变。求职者短信登录、现有密码登录/设置/修改/重置、Token 刷新/登出、管理员初始化、既有企业资料和企业认证接口保持原路径与行为。企业注册后经现有 `/auth/password/login` 成功登录并得到 COMPANY 身份。 |
||||
|
|
||||
|
## 13. 未进入的范围 |
||||
|
|
||||
|
本轮没有实现已认证企业业务门禁、管理员用户/角色管理、`users.version`、`user_management_actions`、岗位发布、简历、Offer 或面试门禁改造;没有提交、推送或进入 Phase B/C。 |
||||
|
|
||||
|
## 14. 当前结束状态 |
||||
|
|
||||
|
验证完成后未暂存、未提交、未推送;无 Java/Maven 残留进程;8080/8081 空闲;临时测试账号与业务数据在隔离容器销毁时清理;未删除审计历史。工作区仍保留既有历史未跟踪文件和两个 Windows stat 缓存假阳性文件,未覆盖、删除或暂存。 |
||||
|
|
||||
|
## 15. 结论 |
||||
|
|
||||
|
实施阶段完成后,独立复验发现功能开关的宽松布尔绑定问题;现已整改为字符串配置并严格使用小写字面量 `true` 判断。事务回滚测试也已补齐 users、user_roles、companies、session、audit 五类独立故障注入,新增回滚覆盖后的完整 Phase A 定向集为 **28/28**,全量回归为 **2865/2865**,均无失败、错误或跳过。 |
||||
@ -0,0 +1,157 @@ |
|||||
|
# Phase A 企业注册与入驻基础闭环独立代码复验报告 |
||||
|
|
||||
|
日期:2026-09-22 |
||||
|
|
||||
|
## 1. 复验范围与边界 |
||||
|
|
||||
|
本次复验针对 Phase A:企业专用注册、`company-register` 验证码用途、验证码原子消费、用户/角色/企业/会话事务、企业入驻聚合查询、测试与 OpenAPI。 |
||||
|
|
||||
|
未进入 Phase B 已认证企业业务门禁,也未进入 Phase C 管理员用户与角色管理;未修改生产代码、未暂存、未提交、未推送。 |
||||
|
|
||||
|
## 2. 前置状态 |
||||
|
|
||||
|
- 分支:`master` |
||||
|
- HEAD:`00153aaf5d92d13e66d6cdc42ecd1fe903d6e666` |
||||
|
- 暂存区:空 |
||||
|
- 迁移:V1–V46 未修改,未新增 V47 |
||||
|
- `pom.xml`:无修改 |
||||
|
- 两个既有 Windows stat 缓存假阳性文件的工作区 blob 与 HEAD 相同,未触碰、未暂存 |
||||
|
- 历史未跟踪验收文件和 `backend-infra` 文件均保留 |
||||
|
|
||||
|
## 3. 独立静态核对 |
||||
|
|
||||
|
通过项: |
||||
|
|
||||
|
1. 注册服务使用事务,按 COMPANY 角色创建用户,并显式写入仅含 COMPANY 的 `user_roles`。 |
||||
|
2. 用户、企业保存后 flush,再创建登录会话;企业初始 `verified=false`。 |
||||
|
3. 注册审计使用固定脱敏文案,未发现密码、验证码、Token 或摘要输出路径。 |
||||
|
4. 验证码消费使用条件更新,条件包含 id、`used=false`、未过期和 attempts 上限,受影响行数必须为 1。 |
||||
|
5. 短信用途采用固定白名单,包含 `company-register`,不接受任意非空用途。 |
||||
|
6. 匿名规则仅新增 `/auth/company/register`;`/companies/me/onboarding` 仍要求认证。 |
||||
|
7. 入驻状态对无记录、PENDING/RESUBMITTED、REJECTED、VERIFIED 和 APPROVED 不一致状态分别处理;不一致状态失败关闭。 |
||||
|
8. DTO 未暴露 `userId`、客户端 `role`、`verified`、`passwordHash` 等不应由客户端控制的字段。 |
||||
|
9. OpenAPI 中新增注册和入驻查询契约,未改变 V1–V46 或既有业务路径。 |
||||
|
|
||||
|
## 4. 定向测试与真实黑盒 |
||||
|
|
||||
|
实施测试与复验前已执行的 Phase A 定向测试为 **23/23,Failures=0,Errors=0,Skipped=0**,其中包含真实 MySQL/Testcontainers 并发注册测试。 |
||||
|
|
||||
|
此前全量结果: |
||||
|
|
||||
|
- `mvn test`:2860/0/0/0 |
||||
|
- `mvn clean verify`:2860/0/0/0 |
||||
|
- OpenAPI:182 paths / 220 operations,All checks passed |
||||
|
|
||||
|
隔离 MySQL 8.4、Redis 7.4、RabbitMQ 3.13 和最新 JAR 的黑盒结果:健康检查通过;发送 `company-register` 验证码后注册成功;密码登录成功;`/auth/me` 返回 COMPANY;入驻查询返回 200;目标数据为 1 个 COMPANY 用户、1 个 COMPANY 角色、1 个企业且 `verified=0`,验证码已消费。临时容器、进程、端口和日志已清理。 |
||||
|
|
||||
|
## 5. 阻断项:功能开关绑定不满足严格字面量要求 |
||||
|
|
||||
|
生产文件:`mydreamjob-server/src/main/java/com/mydreamjob/auth/company/CompanyRegistrationProperties.java` |
||||
|
|
||||
|
当前字段为: |
||||
|
|
||||
|
```java |
||||
|
private boolean enabled = false; |
||||
|
``` |
||||
|
|
||||
|
Spring Boot 标准布尔绑定会把 `TRUE`、`True`、`yes`、`1`、`on` 均转换为 `true`。独立临时测试使用 `ApplicationContextRunner` 真实绑定配置,逐一设置: |
||||
|
|
||||
|
```text |
||||
|
app.company-registration.enabled=TRUE |
||||
|
app.company-registration.enabled=True |
||||
|
app.company-registration.enabled=yes |
||||
|
app.company-registration.enabled=1 |
||||
|
app.company-registration.enabled=on |
||||
|
``` |
||||
|
|
||||
|
复验结果:五个非字面量值均绑定为 enabled=true,测试失败: |
||||
|
|
||||
|
```text |
||||
|
Tests run: 1, Failures: 1, Errors: 0, Skipped: 0 |
||||
|
Expecting empty but was: ["TRUE", "True", "yes", "1", "on"] |
||||
|
``` |
||||
|
|
||||
|
这与设计要求“只有字面量 `true` 才开启,其他值不得误开启”不一致,因此 Phase A 独立复验判定 **FAIL**。临时测试已删除,未留在工作区。 |
||||
|
|
||||
|
整改建议:将配置原始值改为 String,并通过唯一方法严格判断: |
||||
|
|
||||
|
```java |
||||
|
private String enabled = "false"; |
||||
|
|
||||
|
public boolean isEnabled() { |
||||
|
return "true".equals(enabled); |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
同时增加真实 Spring 属性绑定测试,覆盖未设置、`false`、`true`、`TRUE`、`True`、`yes`、`1`、`on`;只有小写字面量 `true` 应开启。 |
||||
|
|
||||
|
## 6. 整改前的次要测试覆盖缺口 |
||||
|
|
||||
|
以下是首次独立复验时发现的证据缺口,不作为已证明的生产缺陷: |
||||
|
|
||||
|
1. `CompanyRegistrationTransactionRollbackTest` 目前主要覆盖审计失败,未分别注入并断言 users 保存、user_roles 保存、companies 保存、会话创建和审计写入五类失败;对 user_roles/auth_tokens 的零残留断言也不完整。 |
||||
|
2. 注册请求中客户端附带未知的 role/verified/passwordHash 字段的既有测试,可能因验证码未准备而返回 400,不能单独证明未知字段被拒绝;DTO 本身未声明这些字段。 |
||||
|
3. tombstone、Redis/数据库/会话依赖异常和每种冲突角色的完整 HTTP 黑盒覆盖仍需单独补齐或明确证据。 |
||||
|
|
||||
|
## 7. 复验结论 |
||||
|
|
||||
|
Phase A 的主要注册、验证码原子消费、事务、并发、入驻聚合和真实中间件路径基本可用,但严格功能开关绑定存在阻断缺陷;另有事务回滚测试覆盖不足。 |
||||
|
|
||||
|
最终判定:**FAIL,暂不能进入独立通过或提交检查点。** |
||||
|
|
||||
|
本次独立复验未修改生产代码、未暂存、未提交、未推送,未进入 Phase B/C。 |
||||
|
|
||||
|
## 8. 阻断整改复验(2026-09-22) |
||||
|
|
||||
|
针对第 5 节阻断,已按最小范围修改: |
||||
|
|
||||
|
- `CompanyRegistrationProperties.enabled` 改为字符串,默认值为 `"false"`; |
||||
|
- `isEnabled()` 仅返回 `"true".equals(enabled)`; |
||||
|
- `CompanyRegistrationFeatureFlagTest` 增加真实 `ApplicationContextRunner` 绑定测试,覆盖 `TRUE`、`True`、`yes`、`1`、`on`、`false` 和 `true`。 |
||||
|
|
||||
|
整改后验证: |
||||
|
|
||||
|
- 功能开关及关联测试:**9/9,F=0、E=0、S=0**; |
||||
|
- Phase A 定向测试:**24/24,F=0、E=0、S=0**; |
||||
|
- `mvn clean verify`:**2861/2861,F=0、E=0、S=0**; |
||||
|
- OpenAPI:**182 paths / 220 operations,All checks passed**。 |
||||
|
|
||||
|
独立绑定测试确认只有字面量 `true` 开启,五种非字面量值均保持关闭。未新增迁移,未进入 Phase B/C,未提交或推送。 |
||||
|
|
||||
|
功能开关阻断已清除;第 6 节的事务回滚覆盖缺口已在第 9 节补齐。 |
||||
|
|
||||
|
说明:第 7 节“FAIL”是整改前复验结论;当前状态以第 8、9 节整改复验结果为准。 |
||||
|
|
||||
|
## 9. 事务回滚覆盖整改复验(2026-09-22) |
||||
|
|
||||
|
`CompanyRegistrationTransactionRollbackTest` 已补齐五类独立故障注入: |
||||
|
|
||||
|
- users 保存失败; |
||||
|
- user_roles 真实插入触发器失败(H2 真实 SQL); |
||||
|
- companies 保存失败; |
||||
|
- 会话创建失败; |
||||
|
- 审计写入失败。 |
||||
|
|
||||
|
每种场景均验证 users、user_roles、companies、auth_tokens 无残留,验证码 `used=false`,并按精确测试手机号清理。测试结果:**5/5,F=0、E=0、S=0**。 |
||||
|
|
||||
|
## 10. 阻断修复后的完整回归收口(2026-09-22) |
||||
|
|
||||
|
为避免把整改前的历史数字误读为当前结果,最终收口结果单独列示如下: |
||||
|
|
||||
|
- 功能开关整改后的独立定向:9/9; |
||||
|
- 五类事务回滚注入:5/5; |
||||
|
- Phase A 完整定向测试:**28/28,F=0、E=0、S=0**; |
||||
|
- `mvn clean verify`:**2865/2865,F=0、E=0、S=0,BUILD SUCCESS**; |
||||
|
- OpenAPI:**182 paths / 220 operations,All checks passed**; |
||||
|
- 真实 MySQL/Testcontainers 并发注册:已执行并通过; |
||||
|
- V1–V46 未修改,未新增 V47;未进入 Phase B/C,仍未暂存、未提交、未推送。 |
||||
|
|
||||
|
期间 H2 user_roles 触发器用于证明真实 SQL 插入失败,触发日志中的预期 SQL 错误属于测试注入证据,不是生产异常;未修改生产错误映射,也未放宽任何断言。 |
||||
|
|
||||
|
## 11. 复验重跑收口(2026-09-22) |
||||
|
|
||||
|
- 独立静态门禁通过:功能开关严格按小写字面量 `true` 开启;匿名放行仅包含 `/auth/company/register`;注册 DTO 未暴露 `userId`/`role`/`verified`/`passwordHash`;验证码白名单、条件消费和 `attempts < maxAttempts` 均已分别在 Service 与 Repository 位置核对;未发现 V47。 |
||||
|
- 首次静态脚本曾把 `attempts < c.maxAttempts` 错查在 `SmsVerificationService.java`,造成脚本假阴性;修正为检查 `SmsVerificationCodeRepository.java` 后门禁通过。该问题仅存在于复验脚本,不是产品缺陷。 |
||||
|
- Phase A 定向重跑:**28/28,Failures=0、Errors=0、Skipped=0**;真实 `CompanyRegistrationConcurrencyMySqlTest` 启动 MySQL 8.4 Testcontainers 并实际执行。 |
||||
|
- OpenAPI:**182 paths / 220 operations,All checks passed**。 |
||||
|
- 收尾:无 Java/Maven 残留进程,8080/8081 无监听,暂存区为空;本轮未修改生产代码、未提交、未推送,未进入 Phase B/C。 |
||||
@ -0,0 +1,24 @@ |
|||||
|
package com.mydreamjob.auth.company; |
||||
|
|
||||
|
import lombok.Getter; |
||||
|
import lombok.Setter; |
||||
|
import org.springframework.boot.context.properties.ConfigurationProperties; |
||||
|
import org.springframework.stereotype.Component; |
||||
|
|
||||
|
/** Feature switch for the public company registration entry point. */ |
||||
|
@Getter |
||||
|
@Setter |
||||
|
@Component |
||||
|
@ConfigurationProperties(prefix = "app.company-registration") |
||||
|
public class CompanyRegistrationProperties { |
||||
|
private String enabled = "false"; |
||||
|
|
||||
|
/** |
||||
|
* Enables registration only for the exact lower-case literal "true". |
||||
|
* Spring's default Boolean converter also accepts values such as "yes" and |
||||
|
* "1", which must not enable this safety-sensitive feature switch. |
||||
|
*/ |
||||
|
public boolean isEnabled() { |
||||
|
return "true".equals(enabled); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,43 @@ |
|||||
|
package com.mydreamjob.auth.company; |
||||
|
|
||||
|
import jakarta.validation.Valid; |
||||
|
import jakarta.validation.constraints.NotBlank; |
||||
|
import jakarta.validation.constraints.NotNull; |
||||
|
import jakarta.validation.constraints.Pattern; |
||||
|
import jakarta.validation.constraints.Size; |
||||
|
import lombok.Data; |
||||
|
|
||||
|
@Data |
||||
|
public class CompanyRegistrationRequest { |
||||
|
@NotBlank |
||||
|
@Pattern(regexp = "[0-9]{11}") |
||||
|
private String phone; |
||||
|
|
||||
|
@NotBlank |
||||
|
@Pattern(regexp = "[0-9]{6}") |
||||
|
private String code; |
||||
|
|
||||
|
@NotBlank |
||||
|
@Size(min = 12, max = 64) |
||||
|
private String password; |
||||
|
|
||||
|
@Valid |
||||
|
@NotNull |
||||
|
private CompanyDetails company; |
||||
|
|
||||
|
@Data |
||||
|
public static class CompanyDetails { |
||||
|
@NotBlank |
||||
|
@Size(max = 100) |
||||
|
private String name; |
||||
|
@Size(max = 500) |
||||
|
private String logoUrl; |
||||
|
private String description; |
||||
|
@Size(max = 80) |
||||
|
private String industry; |
||||
|
@Size(max = 255) |
||||
|
private String website; |
||||
|
@Size(max = 255) |
||||
|
private String address; |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,11 @@ |
|||||
|
package com.mydreamjob.auth.company; |
||||
|
|
||||
|
public record CompanyRegistrationResponse( |
||||
|
Long userId, |
||||
|
Long companyId, |
||||
|
String role, |
||||
|
String onboardingStatus, |
||||
|
String accessToken, |
||||
|
String refreshToken, |
||||
|
long expiresIn) { |
||||
|
} |
||||
@ -0,0 +1,104 @@ |
|||||
|
package com.mydreamjob.auth.company; |
||||
|
|
||||
|
import com.mydreamjob.audit.AuditContext; |
||||
|
import com.mydreamjob.audit.AuditService; |
||||
|
import com.mydreamjob.auth.AuthService; |
||||
|
import com.mydreamjob.auth.AuthSessionService; |
||||
|
import com.mydreamjob.auth.User; |
||||
|
import com.mydreamjob.auth.UserRepository; |
||||
|
import com.mydreamjob.auth.SmsVerificationService; |
||||
|
import com.mydreamjob.common.exception.BusinessException; |
||||
|
import com.mydreamjob.common.exception.ErrorCode; |
||||
|
import com.mydreamjob.company.Company; |
||||
|
import com.mydreamjob.company.CompanyRepository; |
||||
|
import com.mydreamjob.deletion.AccountDeletionPhoneHasher; |
||||
|
import com.mydreamjob.deletion.AccountDeletionRepository; |
||||
|
import com.mydreamjob.auth.password.PasswordPolicy; |
||||
|
import lombok.RequiredArgsConstructor; |
||||
|
import org.springframework.dao.DataIntegrityViolationException; |
||||
|
import org.springframework.dao.DataAccessException; |
||||
|
import org.springframework.security.crypto.password.PasswordEncoder; |
||||
|
import org.springframework.stereotype.Service; |
||||
|
import org.springframework.transaction.annotation.Transactional; |
||||
|
|
||||
|
import java.time.Clock; |
||||
|
import java.time.Instant; |
||||
|
import java.time.LocalDateTime; |
||||
|
import java.util.Map; |
||||
|
import java.util.Set; |
||||
|
|
||||
|
@Service |
||||
|
@RequiredArgsConstructor |
||||
|
public class CompanyRegistrationService { |
||||
|
private static final String PURPOSE = "company-register"; |
||||
|
|
||||
|
private final CompanyRegistrationProperties properties; |
||||
|
private final SmsVerificationService smsVerificationService; |
||||
|
private final UserRepository userRepository; |
||||
|
private final CompanyRepository companyRepository; |
||||
|
private final PasswordPolicy passwordPolicy; |
||||
|
private final PasswordEncoder passwordEncoder; |
||||
|
private final AccountDeletionRepository accountDeletionRepository; |
||||
|
private final AccountDeletionPhoneHasher accountDeletionPhoneHasher; |
||||
|
private final AuthSessionService authSessionService; |
||||
|
private final AuditService auditService; |
||||
|
private final Clock clock; |
||||
|
|
||||
|
@Transactional |
||||
|
public CompanyRegistrationResponse register(CompanyRegistrationRequest request, AuditContext ctx) { |
||||
|
if (!properties.isEnabled()) { |
||||
|
throw new BusinessException(ErrorCode.COMPANY_REGISTRATION_DISABLED); |
||||
|
} |
||||
|
String phone = request.getPhone().trim(); |
||||
|
try { |
||||
|
smsVerificationService.verifyCode(phone, request.getCode(), PURPOSE); |
||||
|
String phoneHash = accountDeletionPhoneHasher.hash(phone); |
||||
|
if (accountDeletionRepository.findByDeletedPhoneHash(phoneHash).isPresent()) { |
||||
|
throw new BusinessException(ErrorCode.COMPANY_REGISTRATION_CONFLICT); |
||||
|
} |
||||
|
passwordPolicy.validateNewPassword(request.getPassword(), phone, null, passwordEncoder); |
||||
|
|
||||
|
if (userRepository.findByPhoneForUpdate(phone).isPresent()) { |
||||
|
throw new BusinessException(ErrorCode.COMPANY_REGISTRATION_CONFLICT); |
||||
|
} |
||||
|
|
||||
|
User user = User.builder() |
||||
|
.phone(phone) |
||||
|
.nickname("企业_" + phone.substring(7)) |
||||
|
.passwordHash(passwordEncoder.encode(passwordPolicy.normalize(request.getPassword()))) |
||||
|
.passwordChangedAt(LocalDateTime.ofInstant(clock.instant(), AuthSessionService.BUSINESS_ZONE)) |
||||
|
.role(User.Role.COMPANY) |
||||
|
.roles(Set.of(User.Role.COMPANY)) |
||||
|
.active(true) |
||||
|
.verified(false) |
||||
|
.build(); |
||||
|
user = userRepository.saveAndFlush(user); |
||||
|
|
||||
|
CompanyRegistrationRequest.CompanyDetails d = request.getCompany(); |
||||
|
Company company = Company.builder() |
||||
|
.ownerUserId(user.getId()) |
||||
|
.name(d.getName()) |
||||
|
.logoUrl(d.getLogoUrl()) |
||||
|
.description(d.getDescription()) |
||||
|
.industry(d.getIndustry()) |
||||
|
.website(d.getWebsite()) |
||||
|
.address(d.getAddress()) |
||||
|
.verified(false) |
||||
|
.build(); |
||||
|
company = companyRepository.saveAndFlush(company); |
||||
|
|
||||
|
Instant now = clock.instant(); |
||||
|
AuthSessionService.SessionTokens tokens = authSessionService.createSession(user, now, ctx); |
||||
|
auditService.logRequired(user.getId(), "COMPANY_REGISTERED", "COMPANY", company.getId(), |
||||
|
"{\"result\":\"SUCCESS\"}", ctx, |
||||
|
LocalDateTime.ofInstant(now, AuthSessionService.BUSINESS_ZONE)); |
||||
|
return new CompanyRegistrationResponse(user.getId(), company.getId(), User.Role.COMPANY.name(), |
||||
|
"UNVERIFIED", tokens.accessToken(), tokens.refreshToken(), AuthService.EXPIRES_IN_SECONDS); |
||||
|
} catch (DataIntegrityViolationException e) { |
||||
|
throw new BusinessException(ErrorCode.COMPANY_REGISTRATION_CONFLICT); |
||||
|
} catch (DataAccessException e) { |
||||
|
// 数据库、Redis 或会话持久化故障统一失败关闭,不把 SQL/连接信息返回给客户端。
|
||||
|
throw new BusinessException(ErrorCode.COMPANY_REGISTRATION_DEPENDENCY_UNAVAILABLE); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,20 @@ |
|||||
|
package com.mydreamjob.company; |
||||
|
|
||||
|
import java.time.LocalDateTime; |
||||
|
import java.util.List; |
||||
|
|
||||
|
public record CompanyOnboardingResponse( |
||||
|
CompanySummary company, |
||||
|
String onboardingStatus, |
||||
|
VerificationSummary latestVerification, |
||||
|
List<String> missingFields, |
||||
|
List<String> allowedActions) { |
||||
|
|
||||
|
public record CompanySummary(Long id, String name, String logoUrl, String description, |
||||
|
String industry, String website, String address, Boolean verified) { |
||||
|
} |
||||
|
|
||||
|
public record VerificationSummary(Long id, String status, LocalDateTime submittedAt, |
||||
|
LocalDateTime decidedAt) { |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,69 @@ |
|||||
|
package com.mydreamjob.company; |
||||
|
|
||||
|
import com.mydreamjob.common.exception.BusinessException; |
||||
|
import com.mydreamjob.common.exception.ErrorCode; |
||||
|
import com.mydreamjob.security.UserRoleGuard; |
||||
|
import com.mydreamjob.verification.CompanyVerification; |
||||
|
import com.mydreamjob.verification.CompanyVerificationRepository; |
||||
|
import lombok.RequiredArgsConstructor; |
||||
|
import org.springframework.stereotype.Service; |
||||
|
import org.springframework.transaction.annotation.Transactional; |
||||
|
|
||||
|
import java.util.ArrayList; |
||||
|
import java.util.List; |
||||
|
|
||||
|
@Service |
||||
|
@RequiredArgsConstructor |
||||
|
public class CompanyOnboardingService { |
||||
|
private final UserRoleGuard userRoleGuard; |
||||
|
private final CompanyRepository companyRepository; |
||||
|
private final CompanyVerificationRepository verificationRepository; |
||||
|
|
||||
|
@Transactional(readOnly = true) |
||||
|
public CompanyOnboardingResponse get(Long userId) { |
||||
|
userRoleGuard.requireCompany(userId); |
||||
|
Company company = companyRepository.findByOwnerUserId(userId) |
||||
|
.orElseThrow(() -> new BusinessException(ErrorCode.COMPANY_NOT_FOUND)); |
||||
|
CompanyVerification latest = verificationRepository.findTopByCompanyIdOrderByIdDesc(company.getId()) |
||||
|
.orElse(null); |
||||
|
|
||||
|
String status; |
||||
|
if (Boolean.TRUE.equals(company.getVerified())) { |
||||
|
status = "VERIFIED"; |
||||
|
} else if (latest == null) { |
||||
|
status = "UNVERIFIED"; |
||||
|
} else if ("PENDING".equals(latest.getStatus()) || "RESUBMITTED".equals(latest.getStatus())) { |
||||
|
status = "PENDING"; |
||||
|
} else if ("REJECTED".equals(latest.getStatus())) { |
||||
|
status = "REJECTED"; |
||||
|
} else if ("APPROVED".equals(latest.getStatus())) { |
||||
|
throw new BusinessException(ErrorCode.COMPANY_ONBOARDING_INCONSISTENT_STATE); |
||||
|
} else { |
||||
|
throw new BusinessException(ErrorCode.COMPANY_ONBOARDING_INCONSISTENT_STATE); |
||||
|
} |
||||
|
|
||||
|
List<String> missing = new ArrayList<>(); |
||||
|
if (isBlank(company.getName())) missing.add("name"); |
||||
|
if (isBlank(company.getIndustry())) missing.add("industry"); |
||||
|
if (isBlank(company.getDescription())) missing.add("description"); |
||||
|
if (isBlank(company.getAddress())) missing.add("address"); |
||||
|
|
||||
|
List<String> actions = switch (status) { |
||||
|
case "UNVERIFIED", "REJECTED" -> List.of("UPDATE_PROFILE", "SUBMIT_VERIFICATION"); |
||||
|
case "PENDING" -> List.of("UPDATE_PROFILE", "WAIT_REVIEW"); |
||||
|
case "VERIFIED" -> List.of("ENTER_COMPANY_WORKSPACE"); |
||||
|
default -> List.of(); |
||||
|
}; |
||||
|
CompanyOnboardingResponse.VerificationSummary verification = latest == null ? null |
||||
|
: new CompanyOnboardingResponse.VerificationSummary(latest.getId(), latest.getStatus(), |
||||
|
latest.getSubmittedAt(), latest.getDecidedAt()); |
||||
|
CompanyOnboardingResponse.CompanySummary summary = new CompanyOnboardingResponse.CompanySummary( |
||||
|
company.getId(), company.getName(), company.getLogoUrl(), company.getDescription(), |
||||
|
company.getIndustry(), company.getWebsite(), company.getAddress(), company.getVerified()); |
||||
|
return new CompanyOnboardingResponse(summary, status, verification, missing, actions); |
||||
|
} |
||||
|
|
||||
|
private boolean isBlank(String value) { |
||||
|
return value == null || value.isBlank(); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,27 @@ |
|||||
|
package com.mydreamjob.auth; |
||||
|
|
||||
|
import com.mydreamjob.audit.AuditService; |
||||
|
import com.mydreamjob.auth.sms.SmsCodeSender; |
||||
|
import com.mydreamjob.common.exception.BusinessException; |
||||
|
import org.junit.jupiter.api.Test; |
||||
|
import org.junit.jupiter.api.extension.ExtendWith; |
||||
|
import org.mockito.Mock; |
||||
|
import org.mockito.junit.jupiter.MockitoExtension; |
||||
|
import org.springframework.data.redis.core.RedisTemplate; |
||||
|
|
||||
|
import static org.assertj.core.api.Assertions.assertThatThrownBy; |
||||
|
|
||||
|
@ExtendWith(MockitoExtension.class) |
||||
|
class SmsPurposeAllowlistTest { |
||||
|
@Mock SmsVerificationCodeRepository codes; |
||||
|
@Mock RedisTemplate<String, Object> redis; |
||||
|
@Mock AuditService audit; |
||||
|
@Mock SmsCodeSender sender; |
||||
|
|
||||
|
@Test |
||||
|
void rejectsArbitraryPurposeBeforeWritingCode() { |
||||
|
SmsVerificationService service = new SmsVerificationService(codes, redis, audit, sender); |
||||
|
assertThatThrownBy(() -> service.sendCode("19900000112", "arbitrary-purpose", null, null, null)) |
||||
|
.isInstanceOf(BusinessException.class); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,92 @@ |
|||||
|
package com.mydreamjob.auth; |
||||
|
|
||||
|
import com.mydreamjob.auth.sms.SmsCodeSender; |
||||
|
import com.mydreamjob.audit.AuditService; |
||||
|
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.junit.jupiter.api.extension.ExtendWith; |
||||
|
import org.mockito.InjectMocks; |
||||
|
import org.mockito.Mock; |
||||
|
import org.mockito.junit.jupiter.MockitoExtension; |
||||
|
import org.springframework.data.redis.core.RedisTemplate; |
||||
|
import org.springframework.data.redis.core.ValueOperations; |
||||
|
|
||||
|
import java.time.LocalDateTime; |
||||
|
import java.util.stream.Stream; |
||||
|
|
||||
|
import static org.assertj.core.api.Assertions.assertThatThrownBy; |
||||
|
import static org.junit.jupiter.api.Assertions.assertTrue; |
||||
|
import static org.mockito.ArgumentMatchers.*; |
||||
|
import static org.mockito.Mockito.*; |
||||
|
|
||||
|
@ExtendWith(MockitoExtension.class) |
||||
|
class SmsVerificationAtomicConsumptionTest { |
||||
|
@Mock SmsVerificationCodeRepository codes; |
||||
|
@Mock RedisTemplate<String, Object> redis; |
||||
|
@Mock ValueOperations<String, Object> values; |
||||
|
@Mock AuditService audit; |
||||
|
@Mock SmsCodeSender sender; |
||||
|
@InjectMocks SmsVerificationService service; |
||||
|
|
||||
|
@BeforeEach |
||||
|
void noLockout() { |
||||
|
lenient().when(redis.hasKey(anyString())).thenReturn(false); |
||||
|
lenient().when(redis.opsForValue()).thenReturn(values); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void successfulCodeUsesConditionalUpdateAndNeverEntitySave() { |
||||
|
SmsVerificationCode code = code(false, 0, 5); |
||||
|
when(codes.streamLatestUnusedByPhoneAndPurpose("19900000201", "company-register")) |
||||
|
.thenReturn(Stream.of(code)); |
||||
|
when(codes.consumeIfValid(eq(7L), any())).thenReturn(1); |
||||
|
code.setId(7L); |
||||
|
|
||||
|
assertTrue(service.verifyCode("19900000201", "123456", "company-register")); |
||||
|
verify(codes).consumeIfValid(eq(7L), any()); |
||||
|
verify(codes, never()).save(any(SmsVerificationCode.class)); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void zeroAffectedRowsIsAConsumedOrInvalidCode() { |
||||
|
SmsVerificationCode code = code(false, 0, 5); |
||||
|
code.setId(8L); |
||||
|
when(codes.streamLatestUnusedByPhoneAndPurpose("19900000202", "company-register")) |
||||
|
.thenReturn(Stream.of(code)); |
||||
|
when(codes.consumeIfValid(eq(8L), any())).thenReturn(0); |
||||
|
|
||||
|
assertThatThrownBy(() -> service.verifyCode("19900000202", "123456", "company-register")) |
||||
|
.isInstanceOf(BusinessException.class) |
||||
|
.extracting("errorCodeInt").isEqualTo(ErrorCode.SMS_CODE_INVALID.getCode()); |
||||
|
verify(codes, never()).save(any(SmsVerificationCode.class)); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void unsupportedPurposeIsRejectedBeforeAnyReadOrUpdate() { |
||||
|
assertThatThrownBy(() -> service.verifyCode("19900000203", "123456", "arbitrary")) |
||||
|
.isInstanceOf(BusinessException.class) |
||||
|
.extracting("errorCodeInt").isEqualTo(ErrorCode.BAD_REQUEST.getCode()); |
||||
|
verifyNoInteractions(codes); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void attemptsAtLimitAreNotConsumedAgain() { |
||||
|
SmsVerificationCode code = code(false, 5, 5); |
||||
|
code.setId(9L); |
||||
|
when(codes.streamLatestUnusedByPhoneAndPurpose("19900000204", "company-register")) |
||||
|
.thenReturn(Stream.of(code)); |
||||
|
|
||||
|
assertThatThrownBy(() -> service.verifyCode("19900000204", "123456", "company-register")) |
||||
|
.isInstanceOf(BusinessException.class) |
||||
|
.extracting("errorCodeInt").isEqualTo(ErrorCode.SMS_CODE_ATTEMPTS_EXCEEDED.getCode()); |
||||
|
verify(codes, never()).consumeIfValid(anyLong(), any()); |
||||
|
} |
||||
|
|
||||
|
private SmsVerificationCode code(boolean used, int attempts, int maxAttempts) { |
||||
|
return SmsVerificationCode.builder().id(7L).phone("19900000201").code("123456") |
||||
|
.purpose("company-register").used(used).attempts(attempts).maxAttempts(maxAttempts) |
||||
|
.createdAt(LocalDateTime.now()).expiredAt(LocalDateTime.now().plusMinutes(5)).build(); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,119 @@ |
|||||
|
package com.mydreamjob.auth.company; |
||||
|
|
||||
|
import com.mydreamjob.audit.AuditContext; |
||||
|
import com.mydreamjob.auth.*; |
||||
|
import com.mydreamjob.company.CompanyRepository; |
||||
|
import org.junit.jupiter.api.AfterEach; |
||||
|
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.data.redis.core.RedisTemplate; |
||||
|
import org.springframework.jdbc.core.JdbcTemplate; |
||||
|
import org.springframework.test.context.ActiveProfiles; |
||||
|
import org.springframework.test.context.DynamicPropertyRegistry; |
||||
|
import org.springframework.test.context.DynamicPropertySource; |
||||
|
import org.testcontainers.containers.MySQLContainer; |
||||
|
import org.testcontainers.junit.jupiter.Container; |
||||
|
import org.testcontainers.junit.jupiter.Testcontainers; |
||||
|
|
||||
|
import java.time.LocalDateTime; |
||||
|
import java.util.ArrayList; |
||||
|
import java.util.List; |
||||
|
import java.util.concurrent.CountDownLatch; |
||||
|
import java.util.concurrent.ExecutorService; |
||||
|
import java.util.concurrent.Executors; |
||||
|
import java.util.concurrent.Future; |
||||
|
import java.util.concurrent.TimeUnit; |
||||
|
|
||||
|
import static org.assertj.core.api.Assertions.assertThat; |
||||
|
import static org.mockito.ArgumentMatchers.anyString; |
||||
|
import static org.mockito.Mockito.when; |
||||
|
|
||||
|
@Testcontainers(disabledWithoutDocker = false) |
||||
|
@SpringBootTest |
||||
|
@ActiveProfiles("mysql-it") |
||||
|
class CompanyRegistrationConcurrencyMySqlTest { |
||||
|
private static final String PHONE = "19900000321"; |
||||
|
private static final String CODE = "123456"; |
||||
|
|
||||
|
@Container |
||||
|
static final MySQLContainer<?> MYSQL = new MySQLContainer<>("mysql:8.4") |
||||
|
.withDatabaseName("company_registration_concurrency") |
||||
|
.withUsername("company_registration_test") |
||||
|
.withPassword("company-registration-test"); |
||||
|
|
||||
|
@DynamicPropertySource |
||||
|
static void properties(DynamicPropertyRegistry registry) { |
||||
|
registry.add("spring.datasource.url", MYSQL::getJdbcUrl); |
||||
|
registry.add("spring.datasource.username", MYSQL::getUsername); |
||||
|
registry.add("spring.datasource.password", MYSQL::getPassword); |
||||
|
registry.add("app.company-registration.enabled", () -> "true"); |
||||
|
} |
||||
|
|
||||
|
@Autowired CompanyRegistrationService service; |
||||
|
@Autowired SmsVerificationCodeRepository codes; |
||||
|
@Autowired UserRepository users; |
||||
|
@Autowired CompanyRepository companies; |
||||
|
@Autowired JdbcTemplate jdbc; |
||||
|
@MockBean RedisTemplate<String, Object> redis; |
||||
|
|
||||
|
@AfterEach |
||||
|
void cleanup() { |
||||
|
jdbc.update("DELETE FROM auth_tokens WHERE user_id IN (SELECT id FROM users WHERE phone = ?)", PHONE); |
||||
|
jdbc.update("DELETE FROM companies WHERE owner_user_id IN (SELECT id FROM users WHERE phone = ?)", PHONE); |
||||
|
jdbc.update("DELETE FROM user_roles WHERE user_id IN (SELECT id FROM users WHERE phone = ?)", PHONE); |
||||
|
jdbc.update("DELETE FROM users WHERE phone = ?", PHONE); |
||||
|
jdbc.update("DELETE FROM sms_verification_codes WHERE phone = ?", PHONE); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void oneCodeCanCreateOnlyOneCompanyIdentityWithoutTimingSleeps() throws Exception { |
||||
|
when(redis.hasKey(anyString())).thenReturn(false); |
||||
|
codes.saveAndFlush(SmsVerificationCode.builder().phone(PHONE).purpose("company-register") |
||||
|
.code(CODE).attempts(0).maxAttempts(5).used(false) |
||||
|
.createdAt(LocalDateTime.now()).expiredAt(LocalDateTime.now().plusMinutes(5)).build()); |
||||
|
|
||||
|
ExecutorService pool = Executors.newFixedThreadPool(2); |
||||
|
CountDownLatch ready = new CountDownLatch(2); |
||||
|
CountDownLatch start = new CountDownLatch(1); |
||||
|
List<Future<Object>> futures = new ArrayList<>(); |
||||
|
for (int i = 0; i < 2; i++) { |
||||
|
futures.add(pool.submit(() -> { |
||||
|
ready.countDown(); |
||||
|
assertThat(start.await(10, TimeUnit.SECONDS)).isTrue(); |
||||
|
try { |
||||
|
return service.register(request(), AuditContext.builder().build()); |
||||
|
} catch (Throwable error) { |
||||
|
return error; |
||||
|
} |
||||
|
})); |
||||
|
} |
||||
|
assertThat(ready.await(10, TimeUnit.SECONDS)).isTrue(); |
||||
|
start.countDown(); |
||||
|
Object first = futures.get(0).get(30, TimeUnit.SECONDS); |
||||
|
Object second = futures.get(1).get(30, TimeUnit.SECONDS); |
||||
|
pool.shutdownNow(); |
||||
|
|
||||
|
long successes = List.of(first, second).stream().filter(CompanyRegistrationResponse.class::isInstance).count(); |
||||
|
assertThat(successes).isEqualTo(1); |
||||
|
assertThat(users.findAll().stream().filter(u -> PHONE.equals(u.getPhone())).count()).isEqualTo(1); |
||||
|
Long userId = users.findByPhone(PHONE).orElseThrow().getId(); |
||||
|
assertThat(jdbc.queryForList("SELECT role FROM user_roles WHERE user_id = ?", String.class, userId)) |
||||
|
.containsExactly("COMPANY"); |
||||
|
assertThat(companies.findByOwnerUserId(userId)).isPresent(); |
||||
|
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM auth_tokens WHERE user_id = ?", Long.class, userId)) |
||||
|
.isEqualTo(1L); |
||||
|
assertThat(List.of(first, second).stream().filter(Throwable.class::isInstance) |
||||
|
.map(Object::toString).allMatch(text -> !text.contains("SQL") && !text.contains("phone"))).isTrue(); |
||||
|
} |
||||
|
|
||||
|
private CompanyRegistrationRequest request() { |
||||
|
CompanyRegistrationRequest request = new CompanyRegistrationRequest(); |
||||
|
request.setPhone(PHONE); request.setCode(CODE); request.setPassword("Company-strong-password-2026"); |
||||
|
CompanyRegistrationRequest.CompanyDetails details = new CompanyRegistrationRequest.CompanyDetails(); |
||||
|
details.setName("并发企业"); details.setIndustry("互联网"); details.setDescription("简介"); details.setAddress("地址"); |
||||
|
request.setCompany(details); |
||||
|
return request; |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,55 @@ |
|||||
|
package com.mydreamjob.auth.company; |
||||
|
|
||||
|
import com.mydreamjob.common.exception.BusinessException; |
||||
|
import com.mydreamjob.common.exception.ErrorCode; |
||||
|
import org.junit.jupiter.api.Test; |
||||
|
import org.springframework.boot.context.properties.EnableConfigurationProperties; |
||||
|
import org.springframework.boot.test.context.runner.ApplicationContextRunner; |
||||
|
import org.springframework.context.annotation.Configuration; |
||||
|
|
||||
|
import static org.assertj.core.api.Assertions.assertThat; |
||||
|
|
||||
|
class CompanyRegistrationFeatureFlagTest { |
||||
|
@Test |
||||
|
void defaultIsClosed() { |
||||
|
assertThat(new CompanyRegistrationProperties().isEnabled()).isFalse(); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void onlyBooleanTrueEnablesTheSwitch() { |
||||
|
CompanyRegistrationProperties properties = new CompanyRegistrationProperties(); |
||||
|
properties.setEnabled("true"); |
||||
|
assertThat(properties.isEnabled()).isTrue(); |
||||
|
properties.setEnabled("false"); |
||||
|
assertThat(properties.isEnabled()).isFalse(); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void springBindingAcceptsOnlyExactLowercaseTrue() { |
||||
|
ApplicationContextRunner runner = new ApplicationContextRunner() |
||||
|
.withUserConfiguration(BindingConfiguration.class); |
||||
|
|
||||
|
for (String value : new String[]{"TRUE", "True", "yes", "1", "on", "false"}) { |
||||
|
runner.withPropertyValues("app.company-registration.enabled=" + value) |
||||
|
.run(context -> assertThat(context.getBean(CompanyRegistrationProperties.class).isEnabled()) |
||||
|
.as("feature flag value %s", value) |
||||
|
.isFalse()); |
||||
|
} |
||||
|
|
||||
|
runner.withPropertyValues("app.company-registration.enabled=true") |
||||
|
.run(context -> assertThat(context.getBean(CompanyRegistrationProperties.class).isEnabled()) |
||||
|
.isTrue()); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void disabledErrorIsStableAndDoesNotExposeRequestData() { |
||||
|
BusinessException error = new BusinessException(ErrorCode.COMPANY_REGISTRATION_DISABLED); |
||||
|
assertThat(error.getErrorMessage()).isEqualTo("company registration is disabled"); |
||||
|
assertThat(error.getMessage()).doesNotContain("phone", "password", "code"); |
||||
|
} |
||||
|
|
||||
|
@Configuration(proxyBeanMethods = false) |
||||
|
@EnableConfigurationProperties(CompanyRegistrationProperties.class) |
||||
|
static class BindingConfiguration { |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,133 @@ |
|||||
|
package com.mydreamjob.auth.company; |
||||
|
|
||||
|
import com.mydreamjob.auth.AuthTokenRepository; |
||||
|
import com.mydreamjob.auth.SmsVerificationCode; |
||||
|
import com.mydreamjob.auth.SmsVerificationCodeRepository; |
||||
|
import com.mydreamjob.auth.User; |
||||
|
import com.mydreamjob.auth.UserRepository; |
||||
|
import com.mydreamjob.company.Company; |
||||
|
import com.mydreamjob.company.CompanyRepository; |
||||
|
import org.junit.jupiter.api.Test; |
||||
|
import org.springframework.beans.factory.annotation.Autowired; |
||||
|
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; |
||||
|
import org.springframework.boot.test.context.SpringBootTest; |
||||
|
import org.springframework.boot.test.mock.mockito.MockBean; |
||||
|
import org.springframework.data.redis.core.RedisTemplate; |
||||
|
import org.springframework.http.MediaType; |
||||
|
import org.springframework.jdbc.core.JdbcTemplate; |
||||
|
import org.springframework.security.crypto.password.PasswordEncoder; |
||||
|
import org.springframework.test.context.ActiveProfiles; |
||||
|
import org.springframework.test.context.TestPropertySource; |
||||
|
import org.springframework.transaction.annotation.Transactional; |
||||
|
|
||||
|
import java.time.LocalDateTime; |
||||
|
import java.util.List; |
||||
|
|
||||
|
import static org.assertj.core.api.Assertions.assertThat; |
||||
|
import static org.mockito.ArgumentMatchers.anyString; |
||||
|
import static org.mockito.Mockito.when; |
||||
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; |
||||
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; |
||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; |
||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; |
||||
|
|
||||
|
@SpringBootTest |
||||
|
@AutoConfigureMockMvc |
||||
|
@ActiveProfiles("integration-test") |
||||
|
@TestPropertySource(properties = "app.company-registration.enabled=true") |
||||
|
class CompanyRegistrationIntegrationTest { |
||||
|
private static final String PHONE = "19900000121"; |
||||
|
private static final String CODE = "123456"; |
||||
|
private static final String PASSWORD = "Company-strong-password-2026"; |
||||
|
|
||||
|
@MockBean |
||||
|
private RedisTemplate<String, Object> redisTemplate; |
||||
|
|
||||
|
@Autowired private org.springframework.test.web.servlet.MockMvc mockMvc; |
||||
|
@Autowired private UserRepository userRepository; |
||||
|
@Autowired private CompanyRepository companyRepository; |
||||
|
@Autowired private SmsVerificationCodeRepository codeRepository; |
||||
|
@Autowired private AuthTokenRepository authTokenRepository; |
||||
|
@Autowired private PasswordEncoder passwordEncoder; |
||||
|
@Autowired private JdbcTemplate jdbcTemplate; |
||||
|
|
||||
|
@Test |
||||
|
@Transactional |
||||
|
void companyRegisterCreatesCompanyIdentityAndSessionAndOnboardingView() throws Exception { |
||||
|
when(redisTemplate.hasKey(anyString())).thenReturn(false); |
||||
|
codeRepository.saveAndFlush(SmsVerificationCode.builder() |
||||
|
.phone(PHONE).purpose("company-register").code(CODE) |
||||
|
.attempts(0).maxAttempts(5).used(false) |
||||
|
.createdAt(LocalDateTime.now()).expiredAt(LocalDateTime.now().plusMinutes(5)).build()); |
||||
|
|
||||
|
String body = """ |
||||
|
{"phone":"%s","code":"%s","password":"%s","company": |
||||
|
{"name":"示例科技","industry":"互联网","description":"企业简介", |
||||
|
"website":"https://example.com","address":"上海"}} |
||||
|
""".formatted(PHONE, CODE, PASSWORD); |
||||
|
|
||||
|
String response = mockMvc.perform(post("/auth/company/register") |
||||
|
.contentType(MediaType.APPLICATION_JSON).content(body) |
||||
|
.header("X-Request-Id", "company-register-it")) |
||||
|
.andExpect(status().isOk()) |
||||
|
.andExpect(jsonPath("$.data.userId").isNumber()) |
||||
|
.andExpect(jsonPath("$.data.companyId").isNumber()) |
||||
|
.andExpect(jsonPath("$.data.role").value("COMPANY")) |
||||
|
.andExpect(jsonPath("$.data.onboardingStatus").value("UNVERIFIED")) |
||||
|
.andExpect(jsonPath("$.data.accessToken").isString()) |
||||
|
.andExpect(jsonPath("$.data.refreshToken").isString()) |
||||
|
.andReturn().getResponse().getContentAsString(); |
||||
|
|
||||
|
assertThat(response).doesNotContain(PASSWORD).doesNotContain("passwordHash"); |
||||
|
User user = userRepository.findByPhone(PHONE).orElseThrow(); |
||||
|
assertThat(user.getRole()).isEqualTo(User.Role.COMPANY); |
||||
|
assertThat(user.getRoles()).containsExactly(User.Role.COMPANY); |
||||
|
assertThat(user.getActive()).isTrue(); |
||||
|
assertThat(user.getVerified()).isFalse(); |
||||
|
assertThat(user.getPasswordChangedAt()).isNotNull(); |
||||
|
assertThat(passwordEncoder.matches(PASSWORD, user.getPasswordHash())).isTrue(); |
||||
|
|
||||
|
Company company = companyRepository.findByOwnerUserId(user.getId()).orElseThrow(); |
||||
|
assertThat(company.getVerified()).isFalse(); |
||||
|
assertThat(jdbcTemplate.queryForList( |
||||
|
"SELECT role FROM user_roles WHERE user_id = ?", String.class, user.getId())) |
||||
|
.containsExactly("COMPANY"); |
||||
|
assertThat(authTokenRepository.findAll().stream() |
||||
|
.filter(token -> user.getId().equals(token.getUserId())).toList()).hasSize(1); |
||||
|
assertThat(codeRepository.findByPhoneAndPurposeOrderByCreatedAtDesc(PHONE, "company-register")) |
||||
|
.first().extracting(SmsVerificationCode::getUsed).isEqualTo(true); |
||||
|
|
||||
|
String accessToken = com.fasterxml.jackson.databind.json.JsonMapper.builder().build() |
||||
|
.readTree(response).path("data").path("accessToken").asText(); |
||||
|
mockMvc.perform(get("/companies/me/onboarding") |
||||
|
.header("Authorization", "Bearer " + accessToken)) |
||||
|
.andExpect(status().isOk()) |
||||
|
.andExpect(jsonPath("$.data.onboardingStatus").value("UNVERIFIED")) |
||||
|
.andExpect(jsonPath("$.data.company.verified").value(false)) |
||||
|
.andExpect(jsonPath("$.data.missingFields").isArray()) |
||||
|
.andExpect(jsonPath("$.data.allowedActions[0]").value("UPDATE_PROFILE")); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void invalidRequestShapeIsRejectedBeforePersistence() throws Exception { |
||||
|
mockMvc.perform(post("/auth/company/register") |
||||
|
.contentType(MediaType.APPLICATION_JSON) |
||||
|
.content(""" |
||||
|
{"phone":"bad","code":"123","password":"weak","company":null} |
||||
|
""")) |
||||
|
.andExpect(status().isBadRequest()) |
||||
|
.andExpect(jsonPath("$.code").value(400)); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void companyRegisterDoesNotAcceptClientRoleOrVerificationFields() throws Exception { |
||||
|
mockMvc.perform(post("/auth/company/register") |
||||
|
.contentType(MediaType.APPLICATION_JSON) |
||||
|
.content(""" |
||||
|
{"phone":"19900000122","code":"123456","password":"Company-strong-password-2026", |
||||
|
"role":"ADMIN","verified":true,"passwordHash":"leak", |
||||
|
"company":{"name":"示例科技","industry":"互联网","description":"企业简介","address":"上海"}} |
||||
|
""")) |
||||
|
.andExpect(status().isBadRequest()); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,76 @@ |
|||||
|
package com.mydreamjob.auth.company; |
||||
|
|
||||
|
import com.mydreamjob.audit.AuditContext; |
||||
|
import com.mydreamjob.audit.AuditService; |
||||
|
import com.mydreamjob.auth.AuthSessionService; |
||||
|
import com.mydreamjob.auth.SmsVerificationService; |
||||
|
import com.mydreamjob.auth.User; |
||||
|
import com.mydreamjob.auth.UserRepository; |
||||
|
import com.mydreamjob.auth.password.PasswordPolicy; |
||||
|
import com.mydreamjob.company.Company; |
||||
|
import com.mydreamjob.company.CompanyRepository; |
||||
|
import com.mydreamjob.deletion.AccountDeletionPhoneHasher; |
||||
|
import com.mydreamjob.deletion.AccountDeletionRepository; |
||||
|
import org.junit.jupiter.api.Test; |
||||
|
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; |
||||
|
|
||||
|
import java.time.Clock; |
||||
|
import java.time.Instant; |
||||
|
import java.time.ZoneOffset; |
||||
|
import java.util.Optional; |
||||
|
|
||||
|
import static org.assertj.core.api.Assertions.assertThat; |
||||
|
import static org.mockito.ArgumentMatchers.*; |
||||
|
import static org.mockito.Mockito.*; |
||||
|
|
||||
|
class CompanyRegistrationSensitiveDataTest { |
||||
|
@Test |
||||
|
void responseAndAuditContainNoPasswordCodePhoneHashOrToken() { |
||||
|
String phone = "19900000301"; |
||||
|
String password = "Company-strong-password-2026"; |
||||
|
CompanyRegistrationProperties properties = new CompanyRegistrationProperties(); |
||||
|
properties.setEnabled("true"); |
||||
|
SmsVerificationService sms = mock(SmsVerificationService.class); |
||||
|
UserRepository users = mock(UserRepository.class); |
||||
|
CompanyRepository companies = mock(CompanyRepository.class); |
||||
|
AccountDeletionRepository deletions = mock(AccountDeletionRepository.class); |
||||
|
AccountDeletionPhoneHasher hasher = mock(AccountDeletionPhoneHasher.class); |
||||
|
AuthSessionService sessions = mock(AuthSessionService.class); |
||||
|
AuditService audit = mock(AuditService.class); |
||||
|
when(hasher.hash(phone)).thenReturn("h"); |
||||
|
when(deletions.findByDeletedPhoneHash("h")).thenReturn(Optional.empty()); |
||||
|
when(users.findByPhoneForUpdate(phone)).thenReturn(Optional.empty()); |
||||
|
when(users.saveAndFlush(any(User.class))).thenAnswer(invocation -> { |
||||
|
User user = invocation.getArgument(0); |
||||
|
user.setId(301L); |
||||
|
return user; |
||||
|
}); |
||||
|
when(companies.saveAndFlush(any(Company.class))).thenAnswer(invocation -> { |
||||
|
Company company = invocation.getArgument(0); |
||||
|
company.setId(401L); |
||||
|
return company; |
||||
|
}); |
||||
|
when(sessions.createSession(any(), any(), any())) |
||||
|
.thenReturn(new AuthSessionService.SessionTokens("access-secret", "refresh-secret", "sid")); |
||||
|
|
||||
|
CompanyRegistrationService service = new CompanyRegistrationService(properties, sms, users, companies, |
||||
|
new PasswordPolicy(), new BCryptPasswordEncoder(), deletions, hasher, sessions, audit, |
||||
|
Clock.fixed(Instant.parse("2026-09-22T00:00:00Z"), ZoneOffset.UTC)); |
||||
|
CompanyRegistrationRequest request = new CompanyRegistrationRequest(); |
||||
|
request.setPhone(phone); |
||||
|
request.setCode("123456"); |
||||
|
request.setPassword(password); |
||||
|
CompanyRegistrationRequest.CompanyDetails details = new CompanyRegistrationRequest.CompanyDetails(); |
||||
|
details.setName("安全测试企业"); |
||||
|
details.setIndustry("互联网"); |
||||
|
details.setDescription("简介"); |
||||
|
details.setAddress("地址"); |
||||
|
request.setCompany(details); |
||||
|
|
||||
|
CompanyRegistrationResponse response = service.register(request, AuditContext.builder().build()); |
||||
|
assertThat(response.toString()).doesNotContain(phone, password); |
||||
|
verify(audit).logRequired(anyLong(), eq("COMPANY_REGISTERED"), anyString(), anyLong(), |
||||
|
argThat(detailsText -> !detailsText.contains(phone) && !detailsText.contains(password)), |
||||
|
any(), any()); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,144 @@ |
|||||
|
package com.mydreamjob.auth.company; |
||||
|
|
||||
|
import com.mydreamjob.audit.AuditContext; |
||||
|
import com.mydreamjob.audit.AuditService; |
||||
|
import com.mydreamjob.auth.AuthSessionService; |
||||
|
import com.mydreamjob.auth.SmsVerificationService; |
||||
|
import com.mydreamjob.auth.User; |
||||
|
import com.mydreamjob.auth.UserRepository; |
||||
|
import com.mydreamjob.auth.password.PasswordPolicy; |
||||
|
import com.mydreamjob.company.Company; |
||||
|
import com.mydreamjob.company.CompanyRepository; |
||||
|
import com.mydreamjob.common.exception.BusinessException; |
||||
|
import com.mydreamjob.common.exception.ErrorCode; |
||||
|
import com.mydreamjob.deletion.AccountDeletionPhoneHasher; |
||||
|
import com.mydreamjob.deletion.AccountDeletionRepository; |
||||
|
import org.junit.jupiter.api.BeforeEach; |
||||
|
import org.junit.jupiter.api.Test; |
||||
|
import org.junit.jupiter.api.extension.ExtendWith; |
||||
|
import org.mockito.Mock; |
||||
|
import org.mockito.junit.jupiter.MockitoExtension; |
||||
|
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; |
||||
|
import org.springframework.security.crypto.password.PasswordEncoder; |
||||
|
import org.springframework.dao.DataAccessResourceFailureException; |
||||
|
|
||||
|
import java.time.Clock; |
||||
|
import java.time.Instant; |
||||
|
import java.time.ZoneOffset; |
||||
|
import java.util.Optional; |
||||
|
|
||||
|
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.Mockito.*; |
||||
|
|
||||
|
@ExtendWith(MockitoExtension.class) |
||||
|
class CompanyRegistrationServiceTest { |
||||
|
private static final String PHONE = "19900000111"; |
||||
|
|
||||
|
@Mock SmsVerificationService sms; |
||||
|
@Mock UserRepository users; |
||||
|
@Mock CompanyRepository companies; |
||||
|
@Mock AccountDeletionRepository deletions; |
||||
|
@Mock AccountDeletionPhoneHasher hasher; |
||||
|
@Mock AuthSessionService sessions; |
||||
|
@Mock AuditService audit; |
||||
|
|
||||
|
private final PasswordEncoder encoder = new BCryptPasswordEncoder(); |
||||
|
private final PasswordPolicy policy = new PasswordPolicy(); |
||||
|
private CompanyRegistrationProperties properties; |
||||
|
private CompanyRegistrationService service; |
||||
|
|
||||
|
@BeforeEach |
||||
|
void setUp() { |
||||
|
properties = new CompanyRegistrationProperties(); |
||||
|
properties.setEnabled("true"); |
||||
|
service = new CompanyRegistrationService(properties, sms, users, companies, policy, encoder, |
||||
|
deletions, hasher, sessions, audit, |
||||
|
Clock.fixed(Instant.parse("2026-09-22T00:00:00Z"), ZoneOffset.UTC)); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void createsCompanyUserCompanyAndSession() { |
||||
|
when(hasher.hash(PHONE)).thenReturn("phone-hash"); |
||||
|
when(deletions.findByDeletedPhoneHash("phone-hash")).thenReturn(Optional.empty()); |
||||
|
when(users.findByPhoneForUpdate(PHONE)).thenReturn(Optional.empty()); |
||||
|
when(users.saveAndFlush(any(User.class))).thenAnswer(inv -> { |
||||
|
User u = inv.getArgument(0); |
||||
|
u.setId(11L); |
||||
|
return u; |
||||
|
}); |
||||
|
when(companies.saveAndFlush(any(Company.class))).thenAnswer(inv -> { |
||||
|
Company c = inv.getArgument(0); |
||||
|
c.setId(21L); |
||||
|
return c; |
||||
|
}); |
||||
|
when(sessions.createSession(any(), any(), any())) |
||||
|
.thenReturn(new AuthSessionService.SessionTokens("access", "refresh", "sid")); |
||||
|
|
||||
|
CompanyRegistrationResponse result = service.register(request(), AuditContext.builder().build()); |
||||
|
|
||||
|
assertThat(result.role()).isEqualTo("COMPANY"); |
||||
|
assertThat(result.onboardingStatus()).isEqualTo("UNVERIFIED"); |
||||
|
verify(sms).verifyCode(PHONE, "123456", "company-register"); |
||||
|
verify(users).saveAndFlush(argThat(u -> u.getRole() == User.Role.COMPANY |
||||
|
&& u.getRoles().contains(User.Role.COMPANY) |
||||
|
&& u.getRoles().size() == 1 |
||||
|
&& !u.getVerified() |
||||
|
&& u.getPasswordHash() != null)); |
||||
|
verify(companies).saveAndFlush(argThat(c -> c.getOwnerUserId().equals(11L) |
||||
|
&& !c.getVerified())); |
||||
|
verify(audit).logRequired(eq(11L), eq("COMPANY_REGISTERED"), eq("COMPANY"), eq(21L), |
||||
|
anyString(), any(), any()); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void disabledRegistrationFailsBeforeSmsConsumption() { |
||||
|
properties.setEnabled("false"); |
||||
|
assertThatThrownBy(() -> service.register(request(), AuditContext.builder().build())) |
||||
|
.isInstanceOf(BusinessException.class) |
||||
|
.extracting("errorCodeInt").isEqualTo(ErrorCode.COMPANY_REGISTRATION_DISABLED.getCode()); |
||||
|
verifyNoInteractions(sms, users, companies); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void dependencyFailureIsMappedToStableServiceUnavailableCode() { |
||||
|
when(sms.verifyCode(anyString(), anyString(), eq("company-register"))) |
||||
|
.thenThrow(new DataAccessResourceFailureException("connection details must not escape")); |
||||
|
|
||||
|
assertThatThrownBy(() -> service.register(request(), AuditContext.builder().build())) |
||||
|
.isInstanceOf(BusinessException.class) |
||||
|
.extracting("errorCodeInt") |
||||
|
.isEqualTo(ErrorCode.COMPANY_REGISTRATION_DEPENDENCY_UNAVAILABLE.getCode()); |
||||
|
verifyNoInteractions(users, companies, sessions, audit); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void allExistingPrincipalRolesUseTheSameConflictResponse() { |
||||
|
when(hasher.hash(PHONE)).thenReturn("phone-hash"); |
||||
|
when(deletions.findByDeletedPhoneHash("phone-hash")).thenReturn(Optional.empty()); |
||||
|
for (User.Role role : User.Role.values()) { |
||||
|
reset(users); |
||||
|
User existing = User.builder().id(100L).phone(PHONE).role(role).roles(java.util.Set.of(role)).build(); |
||||
|
when(users.findByPhoneForUpdate(PHONE)).thenReturn(Optional.of(existing)); |
||||
|
assertThatThrownBy(() -> service.register(request(), AuditContext.builder().build())) |
||||
|
.isInstanceOf(BusinessException.class) |
||||
|
.extracting("errorCodeInt") |
||||
|
.isEqualTo(ErrorCode.COMPANY_REGISTRATION_CONFLICT.getCode()); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private CompanyRegistrationRequest request() { |
||||
|
CompanyRegistrationRequest request = new CompanyRegistrationRequest(); |
||||
|
request.setPhone(PHONE); |
||||
|
request.setCode("123456"); |
||||
|
request.setPassword("StrongCompanyPassword#1"); |
||||
|
CompanyRegistrationRequest.CompanyDetails details = new CompanyRegistrationRequest.CompanyDetails(); |
||||
|
details.setName("示例企业"); |
||||
|
details.setIndustry("互联网"); |
||||
|
details.setDescription("企业简介"); |
||||
|
details.setAddress("上海"); |
||||
|
request.setCompany(details); |
||||
|
return request; |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,145 @@ |
|||||
|
package com.mydreamjob.auth.company; |
||||
|
|
||||
|
import com.mydreamjob.audit.AuditContext; |
||||
|
import com.mydreamjob.audit.AuditService; |
||||
|
import com.mydreamjob.auth.*; |
||||
|
import com.mydreamjob.auth.password.PasswordPolicy; |
||||
|
import com.mydreamjob.company.CompanyRepository; |
||||
|
import com.mydreamjob.company.Company; |
||||
|
import org.junit.jupiter.api.AfterEach; |
||||
|
import org.junit.jupiter.api.Test; |
||||
|
import org.springframework.beans.factory.annotation.Autowired; |
||||
|
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; |
||||
|
import org.springframework.boot.test.context.SpringBootTest; |
||||
|
import org.springframework.boot.test.mock.mockito.MockBean; |
||||
|
import org.springframework.boot.test.mock.mockito.SpyBean; |
||||
|
import org.springframework.dao.DataAccessResourceFailureException; |
||||
|
import org.springframework.data.redis.core.RedisTemplate; |
||||
|
import org.springframework.jdbc.core.JdbcTemplate; |
||||
|
import org.springframework.test.context.ActiveProfiles; |
||||
|
import org.springframework.test.context.TestPropertySource; |
||||
|
import org.h2.api.Trigger; |
||||
|
|
||||
|
import java.sql.Connection; |
||||
|
import java.sql.SQLException; |
||||
|
import java.time.LocalDateTime; |
||||
|
import java.time.Instant; |
||||
|
|
||||
|
import static org.assertj.core.api.Assertions.assertThatThrownBy; |
||||
|
import static org.assertj.core.api.Assertions.assertThat; |
||||
|
import static org.mockito.ArgumentMatchers.*; |
||||
|
import static org.mockito.Mockito.doThrow; |
||||
|
|
||||
|
@SpringBootTest |
||||
|
@AutoConfigureMockMvc |
||||
|
@ActiveProfiles("integration-test") |
||||
|
@TestPropertySource(properties = "app.company-registration.enabled=true") |
||||
|
class CompanyRegistrationTransactionRollbackTest { |
||||
|
private static final String PHONE = "19900000311"; |
||||
|
@Autowired CompanyRegistrationService service; |
||||
|
@Autowired SmsVerificationCodeRepository codes; |
||||
|
@Autowired UserRepository users; |
||||
|
@Autowired CompanyRepository companies; |
||||
|
@Autowired AuthTokenRepository tokens; |
||||
|
@Autowired JdbcTemplate jdbc; |
||||
|
@MockBean RedisTemplate<String, Object> redis; |
||||
|
@MockBean AuditService audit; |
||||
|
@SpyBean UserRepository userRepositorySpy; |
||||
|
@SpyBean CompanyRepository companyRepositorySpy; |
||||
|
@SpyBean AuthSessionService authSessionServiceSpy; |
||||
|
|
||||
|
@AfterEach |
||||
|
void cleanup() { |
||||
|
jdbc.update("DELETE FROM auth_tokens WHERE user_id IN (SELECT id FROM users WHERE phone = ?)", PHONE); |
||||
|
jdbc.update("DELETE FROM companies WHERE owner_user_id IN (SELECT id FROM users WHERE phone = ?)", PHONE); |
||||
|
jdbc.update("DELETE FROM user_roles WHERE user_id IN (SELECT id FROM users WHERE phone = ?)", PHONE); |
||||
|
jdbc.update("DELETE FROM users WHERE phone = ?", PHONE); |
||||
|
jdbc.update("DELETE FROM sms_verification_codes WHERE phone = ?", PHONE); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void auditFailureRollsBackUserRoleCompanySessionAndCodeConsumption() { |
||||
|
doThrow(new DataAccessResourceFailureException("audit unavailable")).when(audit) |
||||
|
.logRequired(anyLong(), eq("COMPANY_REGISTERED"), anyString(), anyLong(), anyString(), any(), any()); |
||||
|
assertRollbackAfterInjectedFailure("audit"); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void userSaveFailureRollsBackAllRowsAndCodeConsumption() { |
||||
|
doThrow(new DataAccessResourceFailureException("user store unavailable")) |
||||
|
.when(userRepositorySpy).saveAndFlush(any(User.class)); |
||||
|
assertRollbackAfterInjectedFailure("user"); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void userRoleFlushFailureRollsBackAllRowsAndCodeConsumption() { |
||||
|
createUserRoleFailureTrigger(); |
||||
|
try { |
||||
|
assertRollbackAfterInjectedFailure("user-role"); |
||||
|
} finally { |
||||
|
jdbc.execute("DROP TRIGGER IF EXISTS phase_a_fail_user_roles"); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void companySaveFailureRollsBackAllRowsAndCodeConsumption() { |
||||
|
doThrow(new DataAccessResourceFailureException("company store unavailable")) |
||||
|
.when(companyRepositorySpy).saveAndFlush(any(Company.class)); |
||||
|
assertRollbackAfterInjectedFailure("company"); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void sessionCreationFailureRollsBackAllRowsAndCodeConsumption() { |
||||
|
doThrow(new DataAccessResourceFailureException("session store unavailable")) |
||||
|
.when(authSessionServiceSpy).createSession(any(User.class), any(Instant.class), any(AuditContext.class)); |
||||
|
assertRollbackAfterInjectedFailure("session"); |
||||
|
} |
||||
|
|
||||
|
private void assertRollbackAfterInjectedFailure(String suffix) { |
||||
|
org.mockito.Mockito.when(redis.hasKey(anyString())).thenReturn(false); |
||||
|
codes.saveAndFlush(SmsVerificationCode.builder().phone(PHONE).purpose("company-register") |
||||
|
.code("123456").attempts(0).maxAttempts(5).used(false) |
||||
|
.createdAt(LocalDateTime.now()).expiredAt(LocalDateTime.now().plusMinutes(5)).build()); |
||||
|
|
||||
|
CompanyRegistrationRequest request = new CompanyRegistrationRequest(); |
||||
|
request.setPhone(PHONE); request.setCode("123456"); request.setPassword("Company-strong-password-2026"); |
||||
|
CompanyRegistrationRequest.CompanyDetails details = new CompanyRegistrationRequest.CompanyDetails(); |
||||
|
details.setName("回滚企业-" + suffix); details.setIndustry("互联网"); details.setDescription("简介"); details.setAddress("地址"); |
||||
|
request.setCompany(details); |
||||
|
|
||||
|
assertThatThrownBy(() -> service.register(request, AuditContext.builder().build())) |
||||
|
.isInstanceOf(com.mydreamjob.common.exception.BusinessException.class); |
||||
|
assertThat(users.findByPhone(PHONE)).isEmpty(); |
||||
|
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM user_roles WHERE user_id IN (SELECT id FROM users WHERE phone = ?)", Long.class, PHONE)).isZero(); |
||||
|
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM companies WHERE name = ?", Long.class, "回滚企业-" + suffix)).isZero(); |
||||
|
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM auth_tokens WHERE user_id IN (SELECT id FROM users WHERE phone = ?)", Long.class, PHONE)).isZero(); |
||||
|
assertThat(codes.findByPhoneAndPurposeOrderByCreatedAtDesc(PHONE, "company-register")) |
||||
|
.singleElement().extracting(SmsVerificationCode::getUsed).isEqualTo(false); |
||||
|
} |
||||
|
|
||||
|
private void createUserRoleFailureTrigger() { |
||||
|
jdbc.execute("CREATE TRIGGER phase_a_fail_user_roles BEFORE INSERT ON user_roles " |
||||
|
+ "FOR EACH ROW CALL '" + FailUserRoleInsertTrigger.class.getName() + "'"); |
||||
|
} |
||||
|
|
||||
|
/** H2-only failure injector: the production transaction still executes real SQL. */ |
||||
|
public static final class FailUserRoleInsertTrigger implements Trigger { |
||||
|
@Override |
||||
|
public void init(Connection conn, String schemaName, String triggerName, |
||||
|
String tableName, boolean before, int type) { |
||||
|
} |
||||
|
|
||||
|
@Override |
||||
|
public void fire(Connection conn, Object[] oldRow, Object[] newRow) throws SQLException { |
||||
|
throw new SQLException("user role store unavailable"); |
||||
|
} |
||||
|
|
||||
|
@Override |
||||
|
public void close() { |
||||
|
} |
||||
|
|
||||
|
@Override |
||||
|
public void remove() { |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,46 @@ |
|||||
|
package com.mydreamjob.company; |
||||
|
|
||||
|
import com.mydreamjob.auth.AuthSessionService; |
||||
|
import com.mydreamjob.auth.JwtTokenProvider; |
||||
|
import com.mydreamjob.common.logging.RequestIdAccessor; |
||||
|
import com.mydreamjob.security.CurrentUserAccessor; |
||||
|
import org.junit.jupiter.api.Test; |
||||
|
import org.springframework.beans.factory.annotation.Autowired; |
||||
|
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; |
||||
|
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; |
||||
|
import org.springframework.boot.test.mock.mockito.MockBean; |
||||
|
import org.springframework.data.jpa.mapping.JpaMetamodelMappingContext; |
||||
|
import org.springframework.test.web.servlet.MockMvc; |
||||
|
|
||||
|
import java.util.List; |
||||
|
|
||||
|
import static org.mockito.Mockito.*; |
||||
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; |
||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; |
||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; |
||||
|
|
||||
|
@WebMvcTest(CompanyController.class) |
||||
|
@AutoConfigureMockMvc(addFilters = false) |
||||
|
@MockBean({JpaMetamodelMappingContext.class, JwtTokenProvider.class, AuthSessionService.class, |
||||
|
java.time.Clock.class, RequestIdAccessor.class, CurrentUserAccessor.class}) |
||||
|
class CompanyOnboardingControllerTest { |
||||
|
@Autowired MockMvc mockMvc; |
||||
|
@MockBean CompanyService companyService; |
||||
|
@MockBean CompanyOnboardingService onboardingService; |
||||
|
@MockBean CurrentUserAccessor userAccessor; |
||||
|
|
||||
|
@Test |
||||
|
void onboardingEndpointReturnsSafeAggregateAndUsesAuthenticatedUser() throws Exception { |
||||
|
when(userAccessor.requireUserId()).thenReturn(77L); |
||||
|
when(onboardingService.get(77L)).thenReturn(new CompanyOnboardingResponse( |
||||
|
new CompanyOnboardingResponse.CompanySummary(12L, "企业", null, "简介", "互联网", null, "地址", false), |
||||
|
"UNVERIFIED", null, List.of("INDUSTRY"), List.of("UPDATE_PROFILE", "SUBMIT_VERIFICATION"))); |
||||
|
|
||||
|
mockMvc.perform(get("/companies/me/onboarding")) |
||||
|
.andExpect(status().isOk()) |
||||
|
.andExpect(jsonPath("$.data.onboardingStatus").value("UNVERIFIED")) |
||||
|
.andExpect(jsonPath("$.data.allowedActions[1]").value("SUBMIT_VERIFICATION")) |
||||
|
.andExpect(jsonPath("$.data.company.name").value("企业")); |
||||
|
verify(onboardingService).get(77L); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,84 @@ |
|||||
|
package com.mydreamjob.company; |
||||
|
|
||||
|
import com.mydreamjob.auth.UserRepository; |
||||
|
import com.mydreamjob.common.exception.BusinessException; |
||||
|
import com.mydreamjob.company.CompanyOnboardingResponse.VerificationSummary; |
||||
|
import com.mydreamjob.security.UserRoleGuard; |
||||
|
import com.mydreamjob.verification.CompanyVerification; |
||||
|
import com.mydreamjob.verification.CompanyVerificationRepository; |
||||
|
import org.junit.jupiter.api.Test; |
||||
|
import org.junit.jupiter.api.extension.ExtendWith; |
||||
|
import org.mockito.Mock; |
||||
|
import org.mockito.junit.jupiter.MockitoExtension; |
||||
|
|
||||
|
import java.util.Optional; |
||||
|
|
||||
|
import static org.assertj.core.api.Assertions.assertThat; |
||||
|
import static org.assertj.core.api.Assertions.assertThatThrownBy; |
||||
|
import static org.mockito.Mockito.*; |
||||
|
|
||||
|
@ExtendWith(MockitoExtension.class) |
||||
|
class CompanyOnboardingServiceTest { |
||||
|
@Mock UserRoleGuard guard; |
||||
|
@Mock CompanyRepository companies; |
||||
|
@Mock CompanyVerificationRepository verifications; |
||||
|
|
||||
|
@Test |
||||
|
void derivesUnverifiedAndDoesNotExposeReviewDetails() { |
||||
|
Company company = Company.builder().id(7L).ownerUserId(9L).name("企业") |
||||
|
.industry("互联网").verified(false).build(); |
||||
|
when(companies.findByOwnerUserId(9L)).thenReturn(Optional.of(company)); |
||||
|
when(verifications.findTopByCompanyIdOrderByIdDesc(7L)).thenReturn(Optional.empty()); |
||||
|
|
||||
|
CompanyOnboardingResponse result = new CompanyOnboardingService(guard, companies, verifications).get(9L); |
||||
|
|
||||
|
assertThat(result.onboardingStatus()).isEqualTo("UNVERIFIED"); |
||||
|
assertThat(result.latestVerification()).isNull(); |
||||
|
assertThat(result.allowedActions()).containsExactly("UPDATE_PROFILE", "SUBMIT_VERIFICATION"); |
||||
|
verify(guard).requireCompany(9L); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void derivesPendingWithoutReturningSensitiveMaterial() { |
||||
|
Company company = Company.builder().id(7L).ownerUserId(9L).name("企业") |
||||
|
.industry("互联网").description("简介").address("地址").verified(false).build(); |
||||
|
CompanyVerification verification = CompanyVerification.builder().id(8L).companyId(7L) |
||||
|
.status("RESUBMITTED").materialsJson("secret-material").decisionReason("internal").build(); |
||||
|
when(companies.findByOwnerUserId(9L)).thenReturn(Optional.of(company)); |
||||
|
when(verifications.findTopByCompanyIdOrderByIdDesc(7L)).thenReturn(Optional.of(verification)); |
||||
|
|
||||
|
VerificationSummary result = new CompanyOnboardingService(guard, companies, verifications).get(9L) |
||||
|
.latestVerification(); |
||||
|
|
||||
|
assertThat(result.status()).isEqualTo("RESUBMITTED"); |
||||
|
assertThat(result).hasNoNullFieldsOrPropertiesExcept("submittedAt", "decidedAt"); |
||||
|
verifyNoMoreInteractions(companies, verifications); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void verifiedCompanyWinsOverPendingReview() { |
||||
|
Company company = Company.builder().id(17L).ownerUserId(19L).name("已认证企业") |
||||
|
.verified(true).build(); |
||||
|
when(companies.findByOwnerUserId(19L)).thenReturn(Optional.of(company)); |
||||
|
when(verifications.findTopByCompanyIdOrderByIdDesc(17L)).thenReturn(Optional.of( |
||||
|
CompanyVerification.builder().id(18L).companyId(17L).status("PENDING").build())); |
||||
|
|
||||
|
CompanyOnboardingResponse result = new CompanyOnboardingService(guard, companies, verifications).get(19L); |
||||
|
assertThat(result.onboardingStatus()).isEqualTo("VERIFIED"); |
||||
|
assertThat(result.allowedActions()).containsExactly("ENTER_COMPANY_WORKSPACE"); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void approvedReviewWithoutCompanyVerificationFailsClosed() { |
||||
|
Company company = Company.builder().id(27L).ownerUserId(29L).name("不一致企业") |
||||
|
.verified(false).build(); |
||||
|
when(companies.findByOwnerUserId(29L)).thenReturn(Optional.of(company)); |
||||
|
when(verifications.findTopByCompanyIdOrderByIdDesc(27L)).thenReturn(Optional.of( |
||||
|
CompanyVerification.builder().id(28L).companyId(27L).status("APPROVED").build())); |
||||
|
|
||||
|
assertThatThrownBy(() -> new CompanyOnboardingService(guard, companies, verifications).get(29L)) |
||||
|
.isInstanceOf(BusinessException.class) |
||||
|
.extracting("errorCodeInt") |
||||
|
.isEqualTo(com.mydreamjob.common.exception.ErrorCode.COMPANY_ONBOARDING_INCONSISTENT_STATE.getCode()); |
||||
|
} |
||||
|
} |
||||
Loading…
Reference in new issue