57 changed files with 1532 additions and 141 deletions
@ -0,0 +1,163 @@ |
|||
# Phase B:已认证企业业务门禁实施报告 |
|||
|
|||
日期:2026-09-23 |
|||
范围:仅 Phase B;未暂存、未提交、未推送、未部署。 |
|||
|
|||
## 结论 |
|||
|
|||
Phase B 门禁已在服务层实施并通过全量验证。`mvn test` 与 `mvn clean verify` 均为 **2880 / 0 failures / 0 errors / 0 skipped**;真实 MySQL/Testcontainers 用例实际运行。OpenAPI 检查为 **182 paths / 220 operations,All checks passed**。API 路径和 operation 集合未改,`docs/openapi-rc.json` 无差异。 |
|||
|
|||
本轮没有启动应用部署或连接生产 RabbitMQ。拒绝路径的事件/通知调用通过测试中的 mock `never()` 断言验证;本机 RabbitMQ 容器当时为停止状态,因此不报告未经测量的 broker 队列深度。没有数据库迁移,也没有新增业务状态字段。 |
|||
|
|||
## 1. 开工核查与基线 |
|||
|
|||
- 分支:`master` |
|||
- HEAD:`8c9429108cbd5d85e5245f6c404b2172bf6dd7f3`(Phase A 已交付基线) |
|||
- 暂存区:空;`git diff --check`:exit 0 |
|||
- 工作区:除本轮明确列出的改动外,原有历史未跟踪文件全部保留;未运行 `reset`、`restore`、`checkout`、`clean` 或任何目录级 `git add`。 |
|||
- `git status` 曾将 `CandidateOverviewStatusPolicy.java` 与 `JobPreferenceScoreCalculator.java` 标成修改;逐文件 `git hash-object` 与 HEAD blob 相同,且 `git diff --quiet`,确认是 Windows stat 缓存假阳性,内容未改。 |
|||
- 迁移库存最高为 **V46**;V1–V46 未改、无 V47。 |
|||
- 上线健康接口可见版本仅为 `0.0.1-SNAPSHOT`,不能据此映射出精确线上 commit。Phase B 未部署,线上版本未改变。 |
|||
- Docker 可用;最终全量验证中的 MySQL/Testcontainers 测试真实执行,Skipped=0。 |
|||
|
|||
## 2. 权威设计和门禁语义 |
|||
|
|||
以 `docs/company-onboarding-admin-user-management-design.md` §7 及独立评审为准:企业认证事实只读 `companies.verified`;`users.is_verified` 不用于企业认证判断。 |
|||
|
|||
检查顺序保持为:现有认证/活动账号校验 → `user_roles` 的 COMPANY 身份 → 当前企业资料存在及 owner 归属 → 仅对受限招聘业务检查 `companies.verified=true`。认证门禁不能取代任何角色、归属、资源可见性判断。资源查询前优先校验调用者所属企业;按资源授权的入口仍保留其原归属检查,避免跨企业数据探测。 |
|||
|
|||
未认证、审核中、已拒绝均按 `companies.verified=false` 统一拒绝受限操作,固定响应 `COMPANY_NOT_VERIFIED` / code **5004** / HTTP **403** / 文案 `company not verified`。该错误码与已有数字码无冲突,HTTP 状态集中登记在 `ErrorHttpStatusRegistry`,没有在 Controller 中复制异常映射。 |
|||
|
|||
## 3. 端点库存与允许/禁止矩阵 |
|||
|
|||
逐个检查了 Controller 映射及其 Service 路径。下表中“COMPANY”表示现有 Controller/服务中的 COMPANY 身份要求仍保留;“own/资源归属”表示原企业 owner、岗位、投递或参与者校验仍执行。受限入口在服务端、候选人/Offer/面试等敏感查询或副作用之前执行认证门禁。 |
|||
|
|||
| 接口范围(HTTP method + path) | Controller → Service | Phase B 结果及既有校验 | |
|||
|---|---|---| |
|||
| `POST /jobs`;`PUT /jobs/{jobId}`;`PATCH /jobs/{jobId}/status` | `JobController` → `JobService` | 仍要求 COMPANY 和本企业岗位归属。创建 DRAFT、编辑自己的 DRAFT 允许;已发布内容编辑以及向 `PUBLISHED` 转换要求认证。将自己已发布岗位转为 `CLOSED` 仍允许,保留安全下架能力和旧状态语义。跨企业岗位仍按旧归属错误拒绝。公开 `GET /jobs`、`GET /jobs/search`、`GET /jobs/public/{jobId}`、`GET /companies/{companyId}/jobs` 不改变。 | |
|||
| `GET /company/applications`;`PATCH /applications/{id}/status`;`GET /company/candidates`;`GET /company/candidates/{applicationId}`;`GET /company/candidates/{applicationId}/resume`;`GET /applications/{id}/timeline`(企业分支) | `ApplicationController` → `ApplicationService` / `CompanyResumeAccessPolicy` | COMPANY、公司/岗位归属与既有可见性校验保留。企业认证先于敏感投递/候选人查询;下载门禁先于简历存储读取。拒绝时不读文件;学生本人的投递/timeline 分支不加企业门禁。 | |
|||
| `GET /company/candidates/{applicationId}/scoring`;`GET /company/scoring/dashboard/{overview,job-rankings,job-rankings/{jobId},candidate-rankings,candidate-rankings/{applicationId},trend}` | `CandidateScoringController`、`CompanyScoringDashboardController` → scoring services | 企业评分/候选人评分属于候选人敏感数据,所有公司入口要求 verified;原公司归属与管理员分支保持不变。 | |
|||
| `GET/POST/PUT/DELETE /applications/{applicationId}/feedback` | `CandidateFeedbackController` → `CandidateFeedbackService` | 企业侧 feedback 先校验公司归属及认证,再访问投递或写反馈;学生自己的反馈路径/权限保持原语义。 | |
|||
| `GET /companies/me/jobs/{jobId}/talent-recommendations`;`POST /companies/me/jobs/{jobId}/talent-invitations`;`GET /companies/me/talent-invitations`;`GET /companies/me/talent-invitations/{invitationId}` | `TalentDiscoveryController`、`TalentInvitationController` → respective services | 企业身份、岗位/邀请归属后要求认证;门禁先于候选人匹配、资料加载及邀请写入/通知。`/profile/me/talent-discovery` 与求职者邀请列表/决定接口仍为求职者自身功能。 | |
|||
| `POST/GET /applications/{applicationId}/interviews`;`GET /interviews/me`;`GET/PUT /interviews/{interviewId}`;`POST /interviews/{interviewId}/cancel`、`/complete`;`GET /interviews/{interviewId}/timeline` | `InterviewController` → `InterviewService` | 企业安排、修改、取消、完成及查看面试时先检查企业认证及原面试/投递归属;求职者查看和回应自己的面试保留原路径与行为。 | |
|||
| `PUT/GET /interviews/{interviewId}/score`;`POST /interviews/{interviewId}/score/submit`;`GET /interviews/{interviewId}/score/history` | `InterviewScoreController` → `InterviewScoreService` | 企业侧评分读写要求认证,并保留面试参与者/归属检查;求职者侧不被企业门禁误伤。 | |
|||
| `POST/GET /offers`;`GET/PUT /offers/{id}`;`PATCH /offers/{id}/status`;`GET /offers/{id}/timeline`;`POST /offers/{id}/remind`;`GET/POST /offers/{id}/counter-offers`;`PATCH /offers/{id}/counter-offers/{coId}/accept`、`/reject`;`GET /offers/statistics` | `OfferController` → `OfferService` | 对公司发起、读取、修改、发送/流转 Offer 及统计入口,在查询/写入/提醒副作用之前验证公司。原 application/offer/counter-offer 所有权检查保留。`GET /offers/compare` 为求职者接口,不改。 | |
|||
| `GET/PUT /companies/me/jobs/{jobId}/recommendation-profile`;`GET/PUT /companies/me/match-rule` | recommendation、`MatchRuleController` → services | 公司侧候选人推荐/匹配配置要求 verified,并保留 owner 校验;`/profile/me/match-rule` 等求职者配置不改。 | |
|||
| `PUT /applications/{id}/hr-suitability-score`;`GET /applications/{id}/match`;`POST /applications/{id}/conversation`;`GET/POST /conversations`;`GET /conversations/{id}`、`/{id}/messages`;`POST /conversations/messages`、`/{id}/read`、`/{conversationId}/messages/{messageId}/recall` | match、conversation、`MessagingController` → services | 企业候选人匹配、发起/读取招聘会话和消息均需 verified。会话成员、投递和消息归属校验保留;求职者自己的候选兴趣分/匹配路径不变。消息列表读取还使用明确加载 `user_roles` 的查询,避免 detached lazy roles 导致角色分支错误。 | |
|||
| `GET /notifications`、`/unread-count`;`POST /notifications/{id}/read`、`/read-all`(企业主体) | `NotificationController` → `NotificationService` | 企业用户侧通知查看/读状态操作要求认证,拒绝先于通知列表读取或读状态写入;其他主体语义保留。系统 worker/事件生产路径不套用户发起的企业门禁。 | |
|||
| `GET/POST/PUT /companies/me`;`GET /companies/me/onboarding`;`POST/GET /companies/me/verification` | `CompanyController`、`CompanyVerificationController` → company/onboarding/verification services | 未认证企业仍可查改自己的资料、查询聚合状态、提交和查询自己的认证申请。身份及 owner 关系照旧验证;认证审核状态机未改。 | |
|||
|
|||
### 明确未加门禁的入口 |
|||
|
|||
- 公开岗位、公司资料和公开评价查询没有候选人隐私或企业招聘副作用,保持公开行为。 |
|||
- `CompanyReviewManagementController` 的本企业公开岗位评价/回复用于企业声誉运营,不读取候选人敏感资料,也不是招聘动作;按 Phase B 设计范围不设认证门禁,既有公司归属照旧。 |
|||
- `ApplicationMatchController` 中求职者的 candidate-interest-score、求职者岗位匹配与个人设置仍按原 STUDENT 路径处理。 |
|||
- `OfferExpiryScheduler`/expiry service、匹配通知重试、候选评分 refresh worker、邀请过期清理和通知消费是系统维护/既有任务,不是未认证企业发起的招聘请求;逐一检查没有从这些后台入口调用受限用户写 API,因此不加企业认证门禁,避免误伤既有系统处理。 |
|||
|
|||
## 4. 实施内容和精确文件范围 |
|||
|
|||
新增服务层共用规则 `mydreamjob-server/src/main/java/com/mydreamjob/company/VerifiedCompanyGuard.java`。它只依据传入的 `Company.verified` 判定;角色、活动状态、公司归属和资源归属仍由现有 service/guard 流程负责。 |
|||
|
|||
修改的生产文件(20 个): |
|||
|
|||
- `application/ApplicationService.java`、`application/CompanyResumeAccessPolicy.java` |
|||
- `auth/UserRepository.java` |
|||
- `bidirectionalmatch/BidirectionalMatchService.java`、`MatchConversationService.java`、`MatchRuleService.java` |
|||
- `candidatescore/CandidateScoringEngineService.java`、`candidatescore/dashboard/CompanyScoringDashboardService.java` |
|||
- `common/exception/ErrorCode.java`、`ErrorHttpStatusRegistry.java` |
|||
- `feedback/CandidateFeedbackService.java` |
|||
- `interview/InterviewService.java`、`interviewscore/InterviewScoreService.java` |
|||
- `job/JobService.java` |
|||
- `messaging/MessagingService.java`、`notification/NotificationService.java` |
|||
- `offer/OfferService.java` |
|||
- `recommendation/JobRecommendationService.java` |
|||
- `talentdiscovery/TalentDiscoveryService.java`、`talentinvitation/TalentInvitationService.java` |
|||
|
|||
新增测试类(3 个): |
|||
|
|||
- `phaseb/PhaseBVerifiedCompanyGateContractTest.java`:静态端点—Service 覆盖映射,并锁定认证只看 `companies.verified`。 |
|||
- `phaseb/VerifiedCompanyGuardTest.java`:verified / false / null / company missing 与固定业务码和 403 映射。 |
|||
- `phaseb/VerifiedCompanyGatePersistenceIntegrationTest.java`:真实 HTTP `PATCH /jobs/{id}/status` 对 UNVERIFIED、PENDING、REJECTED 均返回 403/code 5004,并确认 DRAFT 仍未被发布;verified 企业发布成功。 |
|||
|
|||
另有既有 service/controller/persistence 测试被扩展:求职者、企业、管理员认证角色及旧行为回归;简历读取计数与租户边界;Job 发布;Offer、Interview、TalentInvitation 的拒绝副作用;feedback、matching、messaging、notification、scoring、resume、interview-score 的企业测试夹具现在明确使用已认证 Company,避免旧测试把默认 `verified=false` fixture 误当已认证企业。R01-B/R01-C/R01-E 错误映射和消息查询预算测试只调整了 Phase B 新码映射/现有 eager-role 读取所增加的固定查询基线。 |
|||
|
|||
被修改的既有测试文件(27 个): |
|||
|
|||
- `application/ApplicationServiceTest.java`、`CompanyResumeAccessPolicyTest.java`、`CompanyResumeDownloadIntegrationTest.java` |
|||
- `bidirectionalmatch/BidirectionalMatchPersistenceIntegrationTest.java`、`BidirectionalMatchServiceTest.java`、`MatchConversationServiceTest.java`、`MatchRuleServiceTest.java` |
|||
- `candidatescore/CandidateScoringClockConsistencyTest.java`、`CandidateScoringEngineServiceTest.java` |
|||
- `common/exception/GlobalExceptionHandlerStatusContractTest.java` |
|||
- `common/r01b/ErrorHttpStatusRegistryTest.java`、`GlobalExceptionHandlerMappingParityTest.java`、`R01BIndependentHttpMappingParityTest.java` |
|||
- `common/r01e/R01EIndependentCrossDatabaseVerificationTest.java`、`R01EQueryBudgetAcceptanceTest.java` |
|||
- `feedback/CandidateFeedbackRefreshRegistrationTest.java`、`CandidateFeedbackServiceTest.java` |
|||
- `interview/InterviewServiceTest.java` |
|||
- `interviewscore/InterviewScoreControllerTest.java`、`InterviewScorePersistenceIntegrationTest.java`、`InterviewScoreServiceTest.java` |
|||
- `job/JobServiceTest.java` |
|||
- `messaging/MessageRecallHttpIntegrationTest.java`、`MessagingServiceTest.java` |
|||
- `notification/NotificationServiceTest.java` |
|||
- `offer/OfferServiceTest.java` |
|||
- `talentinvitation/TalentInvitationServiceTest.java` |
|||
|
|||
没有修改 Controller mappings、DTO、`pom.xml`、Flyway、V1–V46、OpenAPI 文件、短信服务、Phase A 注册/验证码/Token/企业认证审核流程,也没有任何 Phase C 用户/角色管理代码。 |
|||
|
|||
## 5. TDD 失败证据与测试覆盖 |
|||
|
|||
- 先增加 `JobServiceTest.transitionStatus_unverifiedCompanyCannotPublish`,在尚无门禁时失败:期望 `BusinessException`,旧实现没有抛出。修复前失败日志保存在系统临时目录,不加入仓库。 |
|||
- 对简历策略做单点变异(移除认证判断)后,`CompanyResumeAccessPolicyTest` 新增负向用例失败:未认证时仍访问 `applicationRepository.findById`,与“门禁先于候选人/简历数据读取”相违。源码恢复后,变异目标测试转绿;变异失败日志保存在系统临时目录。 |
|||
- HTTP 集成覆盖 UNVERIFIED/PENDING/REJECTED 统一 403 及 verified 可发布。跨企业资源仍由原 service ownership tests 和既有集成回归覆盖。 |
|||
- 简历测试包含 storage read probe,拒绝路径 `READ_CALLS=0`。Job 拒绝后状态仍 DRAFT。Offer、Interview、TalentInvitation 的未认证路径对投递/岗位/候选人/offer/interview/invitation/history/audit/notification/刷新等依赖均断言 `never()`,无业务写入或通知触发。 |
|||
- 无效 Token、匿名、STUDENT、ADMIN 的 401/403 使用现有 SecurityFilterChain/Controller 回归;Phase B 没有放开匿名路由,也没有改 SecurityConfig。 |
|||
|
|||
本轮测试驱动过程中的非产品问题:部分旧单测严格 Mockito stubbing 仍 stub 旧的 `findById`;用户/角色集合在 service 离开事务后为 lazy,导致企业身份分支不能可靠判定。通过更新这些测试的 stub,并新增 `UserRepository.findByIdWithRoles` 明确加载 `user_roles` 修复;没有降低断言,也没有删角色检查。OpenAPI 初次调用时使用了不存在的 `docs/verify/_openapi.py` 路径;依据仓库文件清单改用 `docs/verify_openapi.py` 后全部通过,未改脚本。 |
|||
|
|||
## 6. 验证结果 |
|||
|
|||
| 检查 | 结果 | |
|||
|---|---| |
|||
| Phase B 定向(Contract + Guard + persistence + Job/Application/Resume/Offer/Interview/Invitation 及关联服务) | **189/189,F=0 E=0 S=0**;另外最新版 persistence HTTP 类 **2/2** 单独通过 | |
|||
| 全量 `mvn test` | **2880/2880,F=0 E=0 S=0,BUILD SUCCESS** | |
|||
| 最终 `mvn clean verify` | **2880/2880,F=0 E=0 S=0,BUILD SUCCESS**;MySQL/Testcontainers 实测执行 | |
|||
| `python ..\docs\verify_openapi.py`(从 server 模块目录) | **182 paths / 220 operations,All checks passed** | |
|||
| 数据库 schema/migration | V1–V46 无差异;无迁移需求;无 V47 | |
|||
| OpenAPI/API surface | Controller 路径和 operation 集合未改变;`docs/openapi-rc.json` 无差异 | |
|||
|
|||
Phase B 新增用例共 **12**,全套从 2868 增至 2880。Docker 中 MySQL 8.4/Testcontainers 用例真实执行,Skipped=0。Hikari 在 Testcontainers 销毁/重建连接时输出连接已关闭的校验 WARN;两轮全量均以 0 errors 成功完成。 |
|||
|
|||
## 7. 副作用、安全与已知观测 |
|||
|
|||
- 认证事实静态断言确认 Guard 只读取 `Company.verified`,不读取 `User.isVerified`。 |
|||
- 租户 A 即使已认证仍须通过原 owner/资源关系校验;新门禁不构成跨企业授权。服务先验证企业/权限,再查候选人敏感资源;简历 policy 在 `read(storagePath)` 之前失败关闭。 |
|||
- 拒绝请求不会改变 DRAFT/Offer/Interview/Invitation 等业务记录;未调用 audit、notification、message/event publisher 或 scoring refresh 依赖。没有真实 RabbitMQ 运行,因此不声称 broker queue depth 被独立读取;拒绝路径的 publisher 无交互由测试逐项锁定。 |
|||
- 新增代码和报告未包含凭据、JWT、密码、真实手机号、数据库连接秘密、容器动态口令或本机路径;测试数据使用非真实 fixture。修复前日志留在临时目录,未加入 Git。 |
|||
- 没有通过设置 `verified=true` 绕过生产流程;“verified”测试场景仅在测试 fixture 中按显式场景构造,以验证认证企业原行为。 |
|||
|
|||
## 8. Git 结束状态 |
|||
|
|||
- HEAD 仍为 `8c9429108cbd5d85e5245f6c404b2172bf6dd7f3`,分支仍为 `master`。 |
|||
- 暂存区为空,`git diff --check` exit 0;没有提交、推送、部署或合并。 |
|||
- 新增生产组件和 Phase B 测试及本报告仍未跟踪;所有此前历史未跟踪文件原样保留。 |
|||
- 工作区中 20 个已跟踪生产文件有 Phase B 改动;额外两个显示为修改的既有文件已通过 blob hash 证实内容与 HEAD 完全一致。 |
|||
- 最终核查无 Java/Maven 残留进程,8080/8081 无监听;未停止或删除 Docker 容器/镜像。 |
|||
|
|||
Phase C 管理员用户与角色管理未进入;Phase B 未部署。下一步是独立代码复验,不是提交或部署。 |
|||
|
|||
## 9. 两项独立复验阻断修复(2026-09-23) |
|||
|
|||
独立复验发现岗位发布门禁发生在岗位 ID/归属查询之后,可能通过业务码与 HTTP 状态差异探测跨企业岗位;另发现 `VerifiedCompanyGatePersistenceIntegrationTest` 使用全局 `TestAuthTokens` 数据源注册表,在组合运行时偶发无法签发会话。 |
|||
|
|||
- `JobService.transitionStatus` 对目标 `PUBLISHED` 先调用 `requireVerifiedCompany`,再查询岗位;未认证公司的有效 ID、其他公司 ID 和不存在 ID 均先得到固定认证拒绝,不触发岗位查询。 |
|||
- 新增 `JobRepository.findByIdAndCompanyId`,并用于企业岗位读取、修改和状态操作。跨企业岗位和不存在岗位都按 `JOB_NOT_FOUND`/404 处理;`JobController.getJob` 改为单次归属限定查询,避免泄露资源存在性。 |
|||
- 保留设计允许的未认证企业自有 DRAFT 岗位查看与编辑;已认证企业仍必须通过原企业归属条件,不获得跨企业访问权限。 |
|||
- `VerifiedCompanyGatePersistenceIntegrationTest` 不再调用静态全局 `TestAuthTokens`,改由当前 Spring 测试上下文的真实 `AuthSessionService` 创建会话,Token 和会话记录落在当前事务数据源中,事务结束回滚,不依赖其他测试上下文的注册/销毁顺序。 |
|||
- 先加 HTTP 回归再改生产代码时,新测试复现了原差异:未认证发布外企业岗位得到 400、读取/修改外企业岗位得到 400,而目标契约为认证门禁 403 或与不存在资源一致的 404。更新后的 HTTP 集成类 4/4 通过;岗位与认证边界组合 33/33 通过;原 10 类定向组合现为 **191/191**,F=0/E=0/S=0。 |
|||
- 两条既有跨企业岗位 HTTP 断言改为校验 404/`JOB_NOT_FOUND`;夹具明确设成 VERIFIED,确保发布测试实际走到资源归属门禁而不是被未认证门禁短路。 |
|||
|
|||
修复后验证: |
|||
|
|||
| 检查 | 结果 | |
|||
|---|---| |
|||
| `mvn test` | **2882/2882,F=0/E=0/S=0,BUILD SUCCESS** | |
|||
| `mvn clean verify` | **2882/2882,F=0/E=0/S=0,BUILD SUCCESS**;Testcontainers 真实执行 | |
|||
| `python ..\docs\verify_openapi.py` | **182 paths / 220 operations,All checks passed** | |
|||
|
|||
两项阻断已修复并验证。本轮仍未暂存、提交、推送或部署;Phase B 后续仍需独立复验结论确认后才能进入相应检查点。 |
|||
@ -0,0 +1,136 @@ |
|||
# Phase B「已认证企业业务门禁」独立代码复验与真实 MySQL 黑盒验收 |
|||
|
|||
日期:2026-09-23 |
|||
范围:Phase B 独立代码复验、缺陷修复后回归、MySQL 8.4 + HTTP 黑盒 |
|||
结论:**通过;未暂存、未提交、未推送、未部署** |
|||
|
|||
## 1. 结论摘要 |
|||
|
|||
独立复核确认,招聘类受限操作由 Service 层的共用企业认证门禁保护,判定依据为 `companies.verified`;角色、公司归属和资源归属仍分别执行。补充修复两处复验阻断后,新增真实 MySQL 8.4 + 随机端口 HTTP 黑盒测试通过;Phase B 定向回归 **208/208**,全量 `mvn test` 与 `mvn clean verify` 各 **2884/2884,Failures=0、Errors=0、Skipped=0**。OpenAPI 检查 **182 paths / 220 operations,All checks passed**。 |
|||
|
|||
本轮没有连接或修改服务器生产数据库,没有部署服务,没有修改迁移文件或 `docs/openapi-rc.json`。测试中 Redis 使用 mock,RabbitMQ listener 被关闭,因此本文不把本轮结论扩展为真实 Redis/RabbitMQ 黑盒验收。 |
|||
|
|||
## 2. Git 与工作区边界 |
|||
|
|||
- 分支:`master` |
|||
- HEAD:`8c9429108cbd5d85e5245f6c404b2172bf6dd7f3` |
|||
- upstream:`8c9429108cbd5d85e5245f6c404b2172bf6dd7f3`;ahead/behind `0/0` |
|||
- 暂存区:空;`git diff --check`:exit 0(仅有 Git 的 LF/CRLF 提示) |
|||
- 本地工作区存在既有未暂存改动与历史未跟踪材料。没有清理、覆盖、暂存或删除这些文件。 |
|||
- Phase B 现有差异包含 **22 个已跟踪生产文件 + 新增 `VerifiedCompanyGuard.java`**,以及 **28 个已跟踪测试文件 + 4 个 Phase B 新测试类**。本报告和 Phase B 实施报告均未跟踪;本轮没有执行任何 Git 写入操作。 |
|||
- V1–V46 未修改;未新增 V47;`docs/openapi-rc.json` 未修改。 |
|||
- 未进入 Phase C。 |
|||
|
|||
## 3. 独立审查范围与规则 |
|||
|
|||
复核了 Phase B 实施报告、企业入驻设计及复审文档、`VerifiedCompanyGuard`、服务层调用点、岗位资源归属查询、错误码映射、认证边界测试,以及候选人/简历下载入口。 |
|||
|
|||
规则核对结果: |
|||
|
|||
1. 未认证、审核中、已拒绝企业均以 `companies.verified=false` 拒绝受限招聘业务;未使用 `users.is_verified` 代替企业认证。 |
|||
2. 认证门禁在 Service 业务路径执行,不只依赖 Controller;角色/企业归属/资源可见性仍单独保留。 |
|||
3. 企业资料与认证申请、入驻状态等允许操作未被本轮认证门禁一并封锁。 |
|||
4. 错误使用固定 `COMPANY_NOT_VERIFIED`(HTTP 403),经统一 `ErrorHttpStatusRegistry` 映射,不在 Controller 复制转换逻辑。 |
|||
5. 岗位访问必须按企业范围查找;对外企业岗位记录不存在与不属于当前企业均按相同 `JOB_NOT_FOUND` / 404 处理。 |
|||
|
|||
## 4. 复验阻断及修复 |
|||
|
|||
### 4.1 岗位资源边界与检查顺序 |
|||
|
|||
复验期间确认岗位接口需要同时满足两个条件:未认证企业发布岗位时应被认证门禁拒绝;已认证企业也不能借岗位 ID 探测或操作其他企业的岗位。已调整为: |
|||
|
|||
- 发布状态转换在读取目标岗位前先执行认证门禁,使未认证企业对目标 ID 不获得差异化响应; |
|||
- 已认证企业的岗位读取/更新使用 `findByIdAndCompanyId` 范围查询;跨企业与不存在资源统一返回 `JOB_NOT_FOUND` / 404; |
|||
- 企业自己的草稿维护及既有关闭行为按设计保留。 |
|||
|
|||
覆盖测试:`JobServiceTest`、`AuthenticationBoundaryIntegrationTest`,以及本报告新增的真实 MySQL + HTTP 黑盒。 |
|||
|
|||
### 4.2 持久化认证会话测试接线 |
|||
|
|||
原持久化测试通过静态 Token 测试助手建会话,不能确保会话写入当前 Testcontainers 数据源。测试现改为从 Spring 上下文调用 `AuthSessionService.createSession`,令令牌与当前测试数据库一致,避免测试仅凭伪造/错库 Token 得出授权结论。 |
|||
|
|||
## 5. 新增真实 MySQL + HTTP 黑盒 |
|||
|
|||
新增测试:`mydreamjob-server/src/test/java/com/mydreamjob/phaseb/VerifiedCompanyGateMySqlBlackboxTest.java`。 |
|||
|
|||
- Testcontainers 使用真实 `mysql:8.4`,创建隔离数据库 `phase_b_gate_blackbox`,正式执行 Flyway,并使用 Hibernate `ddl-auto=validate`。 |
|||
- Spring Boot 完整应用在随机端口启动;通过 HTTP socket 请求 `/api/v1/...`,覆盖 Spring Security、Controller、Service、Repository 和真实 MySQL。 |
|||
- 使用精确假号段夹具,测试结束按本类登记的主键清理;容器随后由 Testcontainers 回收。 |
|||
|
|||
结果 **2/2,F=0、E=0、S=0**: |
|||
|
|||
1. UNVERIFIED、PENDING、REJECTED 三种 `companies.verified=false` 企业对岗位发布均返回 403 与 `COMPANY_NOT_VERIFIED`。每个拒绝前后比较 15 张表的稳定行快照,并核对岗位仍为 DRAFT。 |
|||
2. 未认证企业读取候选人列表、候选人详情与简历下载均被拒绝。简历存储 `read()` 计数为 0;响应非 PDF;15 张表拒绝前后逐行快照相同。 |
|||
3. VERIFIED 企业能够发布自己的岗位;不能读取、更新或发布另一企业岗位。外企业资源与不存在资源均返回相同 404/`JOB_NOT_FOUND`,跨企业拒绝前后数据库快照无变化。 |
|||
4. 用户夹具的 `users.is_verified` 被有意设为 true,而公司认证状态分别按测试构造。这证明被测门禁取决于 `companies.verified`,而不是用户通用 verified 字段。 |
|||
|
|||
此黑盒未启动真实 Redis 或 RabbitMQ:RedisTemplate 为 mock,RabbitMQ listener 显式禁用。拒绝请求的数据库快照覆盖 `notifications`、`audit_logs`、`messages` 等表;但这不能替代 broker 队列深度检查,也不能证明真实 Redis 可用性。 |
|||
|
|||
## 6. 测试结果 |
|||
|
|||
| 验证 | 结果 | |
|||
|---|---:| |
|||
| Phase B 定向(门禁/角色/岗位/投递/简历/Offer/面试/邀约/消息等 12 类) | **208/208,F=0、E=0、S=0** | |
|||
| 真实 MySQL + HTTP 黑盒 | **2/2,F=0、E=0、S=0**(包含在定向结果内) | |
|||
| `mvn test` | **2884/2884,F=0、E=0、S=0** | |
|||
| `mvn clean verify` | **2884/2884,F=0、E=0、S=0** | |
|||
| 全量 Surefire XML | 277 个测试类;2884 项;F/E/S 均为 0 | |
|||
| `python ..\docs\verify_openapi.py` | **182 paths / 220 operations,All checks passed** | |
|||
|
|||
MySQL/Testcontainers 在本机 Docker 上真实运行;新增黑盒类标记 `disabledWithoutDocker=false`,不会在 Docker 缺失时静默跳过。全量测试 `Skipped=0`。 |
|||
|
|||
## 7. 测试驱动器自身问题记录 |
|||
|
|||
初次黑盒执行出现 HTTP 404,是 `TestRestTemplate` 测试 URL 使用相对路径造成的 harness 错误;随后改为以 `@LocalServerPort` 构造 `127.0.0.1` 绝对 URL,黑盒 **2/2** 通过。测试初次编译还因 `DirtiesContext` 导入包路径错误失败,改为 Spring Test 正确包后通过。两项均为测试代码接线问题,不是产品缺陷;修正后定向与全量测试均已重跑通过。 |
|||
|
|||
## 8. 运行环境收尾 |
|||
|
|||
检查时 Docker 容器列表为空;8080/8081 无监听;无残留 `java` 或 `mvn` 进程。黑盒使用随机端口,并未占用上述端口。临时 MySQL 为 Testcontainers 隔离容器,没有触及本机开发库或服务器库。 |
|||
|
|||
## 9. 未执行/不在本轮结论内 |
|||
|
|||
- 未做生产服务器部署或真实公网 MySQL/Redis/RabbitMQ 验收。 |
|||
- 未做运行时 OpenAPI 路径集合与静态 OpenAPI 的双集合比较;本轮运行的是仓库 OpenAPI 检查器,且 `docs/openapi-rc.json` 保持未改。 |
|||
- 未覆盖到的每一个招聘入口仍以既有 Phase B 结构/服务测试清单和全量回归为依据;本轮新增真实黑盒聚焦岗位发布、候选人列表/详情/简历,以及跨企业岗位访问,不能解读为每个业务端点都由真实 HTTP 重放。 |
|||
- 未实现 Phase C 管理员用户与角色管理;未修改短信服务;未伪造任何真实企业认证状态。 |
|||
|
|||
## 10. 最终判定 |
|||
|
|||
**Phase B 独立代码复验及真实 MySQL + HTTP 黑盒验收通过。** 两处复验阻断已修复;定向、全量测试及 OpenAPI 检查通过。当前仍是未暂存的本地工作区状态,后续如需提交,必须另行进行提交前精确文件范围审查;本轮没有暂存、提交、推送或部署。 |
|||
|
|||
## 11. 2026-09-29 精确暂存与隔离验证检查点 |
|||
|
|||
本节追加记录最新检查点,不覆盖第 1–10 节的历史执行结果与当时仓库状态。 |
|||
|
|||
### 11.1 批次边界与验证来源 |
|||
|
|||
- 当前分支为 `feat/phase-b-company-verification-gate`;基线 HEAD 为 `bcea6b02955f20f933572e8c8f2c85b1ad05843c`,包含此前单独本地提交的短信 P0 修复。 |
|||
- Phase B 暂存范围为 **57 个文件:23 个生产文件、32 个测试文件、2 份报告**,没有删除文件。未纳入迁移、`pom.xml`、OpenAPI 文件、基础设施脚本或历史验收输出。 |
|||
- 共用错误码与映射测试只暂存 Phase B 的 `COMPANY_NOT_VERIFIED` 及其 403 映射,未纳入统一注册/身份选择的新增错误码和接口。 |
|||
- 验证目录由 HEAD 归档加暂存补丁生成,不读取原工作区未暂存的统一注册/身份选择实现。57 个暂存文件与验证快照按 Git 换行规范化后的 blob 逐项比较,**不一致数为 0**。原工作区其他批次改动与历史未跟踪文件均保留。 |
|||
|
|||
### 11.2 本次失败记录及纠正 |
|||
|
|||
1. 初次 32 类组合运行 **928 项,F=1、E=0、S=0**。简历下载测试的 OpenAPI 数量期望误混入未提交身份选择批次的 183/221,而当前隔离快照实际为 182/220。已从暂存区移除这两行无关 hunk,使用 HEAD 的 182/220 契约;原工作区 183/221 改动仍保留。没有修改生产实现或放宽授权断言。随后 12 类定向及全量验证通过。 |
|||
2. 9 月 28 日首次全量尝试未完成,跨库测试报告 1 项容器启动错误,根因链包含 Docker 命名管道不存在(`docker_engine`)。该次没有 Maven 最终汇总,不记为通过,也不推算其全量测试总数。日志保留在系统临时验证目录,不纳入提交。Docker 恢复后确认服务版本 29.6.2,以原暂存代码快照重新执行完整 `clean verify`。 |
|||
|
|||
### 11.3 最新验证结果 |
|||
|
|||
| 验证 | 实测结果 | |
|||
|---|---:| |
|||
| Phase B 12 类定向测试 | **208 项,F=0、E=0、S=0,BUILD SUCCESS** | |
|||
| Phase B 真实 MySQL + HTTP 黑盒 | **2 项,F=0、E=0、S=0**(包含在定向与全量结果内) | |
|||
| 隔离暂存快照 `mvn clean verify` | **2889 项,F=0、E=0、S=0,BUILD SUCCESS** | |
|||
| 全量 Surefire XML 独立汇总 | **281 个测试类,2889 项,F/E/S 均为 0** | |
|||
| 短信错误次数真实 MySQL 并发测试 | **1 项,F=0、E=0、S=0**(基线短信批次回归) | |
|||
| 短信限流真实 Redis 测试 | **1 项,F=0、E=0、S=0**(基线短信批次回归) | |
|||
| 静态 OpenAPI 检查器 | **182 paths / 220 operations,All checks passed** | |
|||
| Spring 测试上下文运行时 OpenAPI 与静态集合比较 | **双方均 182/220,路径差集 0,路径/HTTP 方法 operation 差集 0** | |
|||
|
|||
本次全量命令使用 `-Ds03.openapi.snapshot=<临时输出目录>` 留存既有简历测试通过 MockMvc 获取的运行时文档;比较以解析后的路径与 HTTP 方法集合为准,不只比较数量。该证据来自完整 Spring 测试上下文,**不是独立 JAR 或公网部署版本的 OpenAPI 验收**。此次没有另行宣称一次新的 `mvn test` 结果;`clean verify` 已实际执行全量测试并打包成功。 |
|||
|
|||
### 11.4 暂存安全扫描与结论边界 |
|||
|
|||
暂存新增行扫描发现 2 处手机号形状的既有测试夹具字面量、1 处明确仅用于临时 Testcontainers 数据库的测试口令;均不是实际用户或服务器凭据。疑似绝对路径命中均为路由数字正则或 loopback 随机端口 HTTP URL,不是本机文件路径。未发现真实 JWT、Bearer、邮箱、BCrypt 摘要、私钥、AWS key 或服务器连接密钥;原始运行日志和容器输出不暂存。 |
|||
|
|||
**Phase B 精确暂存与隔离验证检查点通过。** 本节完成后仅更新并暂存本报告,尚未创建 Phase B 本地提交,未推送、未部署,未进入 Phase C。真实 MySQL HTTP 黑盒的 Redis mock 与禁用 RabbitMQ listener 边界仍如第 5 节所述;本次全量中的真实 Redis 测试不能替代 Phase B 全链路三中间件或公网验收。 |
|||
@ -0,0 +1,41 @@ |
|||
package com.mydreamjob.company; |
|||
|
|||
import com.mydreamjob.auth.User; |
|||
import com.mydreamjob.common.exception.BusinessException; |
|||
import com.mydreamjob.common.exception.ErrorCode; |
|||
|
|||
import java.util.function.Supplier; |
|||
|
|||
/** |
|||
* 企业已认证门禁。角色与企业/资源归属仍由调用 Service 校验;该类只以 |
|||
* {@code companies.verified} 为准判定企业招聘业务是否可继续。 |
|||
*/ |
|||
public final class VerifiedCompanyGuard { |
|||
|
|||
private VerifiedCompanyGuard() { |
|||
} |
|||
|
|||
/** 认证前的公司身份检查已通过后,调用本方法执行唯一的认证状态判定。 */ |
|||
public static Company requireVerified(Company company) { |
|||
if (company == null) { |
|||
throw new BusinessException(ErrorCode.COMPANY_NOT_FOUND); |
|||
} |
|||
if (!Boolean.TRUE.equals(company.getVerified())) { |
|||
throw new BusinessException(ErrorCode.COMPANY_NOT_VERIFIED); |
|||
} |
|||
return company; |
|||
} |
|||
|
|||
/** 公司资料缺失、尚无审核申请或被拒绝,都统一按未认证企业拒绝。 */ |
|||
public static boolean isVerified(Company company) { |
|||
return company != null && Boolean.TRUE.equals(company.getVerified()); |
|||
} |
|||
|
|||
/** 对学生等非企业调用方保持原语义;企业身份则失败关闭。 */ |
|||
public static void requireVerifiedIfCompany(User actor, Supplier<Company> companySupplier) { |
|||
if (actor != null && actor.getRoles() != null |
|||
&& actor.getRoles().contains(User.Role.COMPANY)) { |
|||
requireVerified(companySupplier.get()); |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,156 @@ |
|||
package com.mydreamjob.phaseb; |
|||
|
|||
import org.junit.jupiter.api.Test; |
|||
|
|||
import java.io.IOException; |
|||
import java.nio.file.Files; |
|||
import java.nio.file.Path; |
|||
import java.util.List; |
|||
import java.util.Map; |
|||
|
|||
import static org.junit.jupiter.api.Assertions.assertTrue; |
|||
|
|||
/** Phase B 路由库存与统一业务服务门禁的结构性契约。 */ |
|||
class PhaseBVerifiedCompanyGateContractTest { |
|||
|
|||
private static final Path MAIN = Path.of("src/main/java/com/mydreamjob"); |
|||
|
|||
private static final Map<String, List<String>> ROUTE_INVENTORY = Map.ofEntries( |
|||
Map.entry("application/ApplicationController.java", List.of( |
|||
"@GetMapping(\"/applications/{id}/timeline\")", |
|||
"@GetMapping(\"/company/applications\")", |
|||
"@PatchMapping(\"/applications/{id}/status\")", |
|||
"@GetMapping(\"/company/candidates\")", |
|||
"@GetMapping(\"/company/candidates/{applicationId}\")", |
|||
"@GetMapping(\"/company/candidates/{applicationId}/resume\")")), |
|||
Map.entry("job/JobController.java", List.of( |
|||
"@PostMapping", "@PutMapping(\"/{jobId}\")", "@PatchMapping(\"/{jobId}/status\")")), |
|||
Map.entry("candidatescore/CandidateScoringController.java", List.of( |
|||
"@GetMapping(\"/company/candidates/{applicationId}/scoring\")")), |
|||
Map.entry("feedback/CandidateFeedbackController.java", List.of( |
|||
"@PostMapping(\"/applications/{applicationId}/feedback\")", |
|||
"@GetMapping(\"/applications/{applicationId}/feedback\")", |
|||
"@PutMapping(\"/applications/{applicationId}/feedback\")", |
|||
"@DeleteMapping(\"/applications/{applicationId}/feedback\")")), |
|||
Map.entry("talentdiscovery/TalentDiscoveryController.java", List.of( |
|||
"@GetMapping(\"/companies/me/jobs/{jobId}/talent-recommendations\")")), |
|||
Map.entry("talentinvitation/TalentInvitationController.java", List.of( |
|||
"@PostMapping(\"/companies/me/jobs/{jobId}/talent-invitations\")", |
|||
"@GetMapping(\"/companies/me/talent-invitations\")", |
|||
"@GetMapping(\"/companies/me/talent-invitations/{invitationId}\")")), |
|||
Map.entry("interview/InterviewController.java", List.of( |
|||
"@PostMapping(\"/applications/{applicationId}/interviews\")", |
|||
"@GetMapping(\"/applications/{applicationId}/interviews\")", |
|||
"@GetMapping(\"/interviews/me\")", |
|||
"@GetMapping(\"/interviews/{interviewId}\")", |
|||
"@PutMapping(\"/interviews/{interviewId}\")", |
|||
"@PostMapping(\"/interviews/{interviewId}/cancel\")", |
|||
"@PostMapping(\"/interviews/{interviewId}/complete\")", |
|||
"@GetMapping(\"/interviews/{interviewId}/timeline\")")), |
|||
Map.entry("interviewscore/InterviewScoreController.java", List.of( |
|||
"@PutMapping(\"/interviews/{interviewId}/score\")", |
|||
"@GetMapping(\"/interviews/{interviewId}/score\")", |
|||
"@PostMapping(\"/interviews/{interviewId}/score/submit\")", |
|||
"@GetMapping(\"/interviews/{interviewId}/score/history\")")), |
|||
Map.entry("candidatescore/dashboard/CompanyScoringDashboardController.java", List.of( |
|||
"@GetMapping(\"/company/scoring/dashboard/overview\")", |
|||
"@GetMapping(\"/company/scoring/dashboard/job-rankings\")", |
|||
"@GetMapping(\"/company/scoring/dashboard/job-rankings/{jobId}\")", |
|||
"@GetMapping(\"/company/scoring/dashboard/candidate-rankings\")", |
|||
"@GetMapping(\"/company/scoring/dashboard/candidate-rankings/{applicationId}\")", |
|||
"@GetMapping(\"/company/scoring/dashboard/trend\")")), |
|||
Map.entry("offer/OfferController.java", List.of( |
|||
"@PostMapping", "@GetMapping", "@PutMapping(\"/{id:\\\\d+}\")", |
|||
"@PatchMapping(\"/{id:\\\\d+}/status\")", "@PostMapping(\"/{id:\\\\d+}/remind\")", |
|||
"@GetMapping(\"/{id:\\\\d+}/timeline\")", |
|||
"@GetMapping(\"/{id:\\\\d+}/counter-offers\")", |
|||
"@PostMapping(\"/{id:\\\\d+}/counter-offers\")", |
|||
"@PatchMapping(\"/{id:\\\\d+}/counter-offers/{coId:\\\\d+}/accept\")", |
|||
"@PatchMapping(\"/{id:\\\\d+}/counter-offers/{coId:\\\\d+}/reject\")", |
|||
"@GetMapping(\"/compare\")", "@GetMapping(\"/statistics\")")), |
|||
Map.entry("recommendation/JobRecommendationController.java", List.of( |
|||
"@GetMapping(\"/companies/me/jobs/{jobId}/recommendation-profile\")", |
|||
"@PutMapping(\"/companies/me/jobs/{jobId}/recommendation-profile\")")), |
|||
Map.entry("bidirectionalmatch/ApplicationMatchController.java", List.of( |
|||
"@PutMapping(\"/applications/{id}/hr-suitability-score\")", |
|||
"@GetMapping(\"/applications/{id}/match\")")), |
|||
Map.entry("bidirectionalmatch/MatchConversationController.java", List.of( |
|||
"@PostMapping(\"/applications/{id}/conversation\")")), |
|||
Map.entry("messaging/MessagingController.java", List.of( |
|||
"@GetMapping", "@GetMapping(\"/{id}\")", "@PostMapping", "@PostMapping(\"/messages\")", |
|||
"@GetMapping(\"/{id}/messages\")", "@PostMapping(\"/{id}/read\")", |
|||
"@PostMapping(\"/{conversationId}/messages/{messageId}/recall\")")), |
|||
Map.entry("notification/NotificationController.java", List.of( |
|||
"@GetMapping", "@PostMapping(\"/{id}/read\")", "@PostMapping(\"/read-all\")", |
|||
"@GetMapping(\"/unread-count\")")), |
|||
Map.entry("company/CompanyController.java", List.of( |
|||
"@GetMapping(\"/me/onboarding\")", "@PostMapping(\"/me\")", |
|||
"@GetMapping(\"/me\")", "@PutMapping(\"/me\")")), |
|||
Map.entry("verification/CompanyVerificationController.java", List.of( |
|||
"@PostMapping(\"/companies/me/verification\")", |
|||
"@GetMapping(\"/companies/me/verification\")")), |
|||
Map.entry("bidirectionalmatch/MatchRuleController.java", List.of( |
|||
"@GetMapping(\"/companies/me/match-rule\")", "@PutMapping(\"/companies/me/match-rule\")")) |
|||
); |
|||
|
|||
private static final List<String> SENSITIVE_SERVICES = List.of( |
|||
"job/JobService.java", |
|||
"application/ApplicationService.java", |
|||
"application/CompanyResumeAccessPolicy.java", |
|||
"candidatescore/CandidateScoringEngineService.java", |
|||
"feedback/CandidateFeedbackService.java", |
|||
"talentdiscovery/TalentDiscoveryService.java", |
|||
"talentinvitation/TalentInvitationService.java", |
|||
"interview/InterviewService.java", |
|||
"interviewscore/InterviewScoreService.java", |
|||
"offer/OfferService.java", |
|||
"recommendation/JobRecommendationService.java", |
|||
"bidirectionalmatch/BidirectionalMatchService.java", |
|||
"bidirectionalmatch/MatchConversationService.java", |
|||
"bidirectionalmatch/MatchRuleService.java", |
|||
"messaging/MessagingService.java", |
|||
"notification/NotificationService.java", |
|||
"candidatescore/dashboard/CompanyScoringDashboardService.java" |
|||
); |
|||
|
|||
@Test |
|||
void routeInventoryExistsAndEachSensitiveBusinessServiceUsesTheUnifiedGate() throws IOException { |
|||
for (Map.Entry<String, List<String>> entry : ROUTE_INVENTORY.entrySet()) { |
|||
String source = read(entry.getKey()); |
|||
for (String route : entry.getValue()) { |
|||
assertTrue(source.contains(route), "路由库存缺失:" + entry.getKey() + " " + route); |
|||
} |
|||
} |
|||
for (String service : SENSITIVE_SERVICES) { |
|||
String source = read(service); |
|||
boolean usesUnifiedGate = source.contains("VerifiedCompanyGuard.") |
|||
|| (service.equals("application/ApplicationService.java") |
|||
&& source.contains("requireVerifiedCompany(")); |
|||
assertTrue(usesUnifiedGate, |
|||
"服务未调用统一企业认证门禁:" + service); |
|||
} |
|||
String gate = read("company/VerifiedCompanyGuard.java"); |
|||
assertTrue(gate.contains("company.getVerified()"), "门禁必须读取 companies.verified"); |
|||
assertTrue(!gate.contains("user.getVerified()") && !gate.contains("actor.getVerified()"), |
|||
"门禁不得以 users.is_verified 替代企业认证状态"); |
|||
} |
|||
|
|||
@Test |
|||
void onboardingAndOwnDraftJobRoutesRemainAvailable() throws IOException { |
|||
String company = read("company/CompanyController.java"); |
|||
assertTrue(company.contains("/me/onboarding")); |
|||
assertTrue(company.contains("/me")); |
|||
|
|||
String verification = read("verification/CompanyVerificationController.java"); |
|||
assertTrue(verification.contains("/companies/me/verification")); |
|||
|
|||
String jobs = read("job/JobController.java"); |
|||
assertTrue(jobs.contains("@PostMapping")); |
|||
assertTrue(jobs.contains("@PutMapping(\"/{jobId}\")")); |
|||
assertTrue(jobs.contains("@GetMapping(\"/{jobId}\")")); |
|||
} |
|||
|
|||
private String read(String relative) throws IOException { |
|||
return Files.readString(MAIN.resolve(relative)); |
|||
} |
|||
} |
|||
@ -0,0 +1,336 @@ |
|||
package com.mydreamjob.phaseb; |
|||
|
|||
import com.fasterxml.jackson.databind.JsonNode; |
|||
import com.fasterxml.jackson.databind.ObjectMapper; |
|||
import com.mydreamjob.application.Application; |
|||
import com.mydreamjob.application.ApplicationRepository; |
|||
import com.mydreamjob.auth.AuthSessionService; |
|||
import com.mydreamjob.auth.User; |
|||
import com.mydreamjob.auth.UserRepository; |
|||
import com.mydreamjob.common.exception.ErrorCode; |
|||
import com.mydreamjob.company.Company; |
|||
import com.mydreamjob.company.CompanyRepository; |
|||
import com.mydreamjob.job.Job; |
|||
import com.mydreamjob.job.JobRepository; |
|||
import com.mydreamjob.privacy.ResumeVisibility; |
|||
import com.mydreamjob.privacy.StudentPrivacySettings; |
|||
import com.mydreamjob.privacy.StudentPrivacySettingsRepository; |
|||
import com.mydreamjob.profile.StudentProfile; |
|||
import com.mydreamjob.profile.StudentProfileRepository; |
|||
import com.mydreamjob.profile.storage.LocalResumeStorageService; |
|||
import com.mydreamjob.profile.storage.ResumeStorageService; |
|||
import com.mydreamjob.verification.CompanyVerification; |
|||
import com.mydreamjob.verification.CompanyVerificationRepository; |
|||
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.context.TestConfiguration; |
|||
import org.springframework.boot.test.web.server.LocalServerPort; |
|||
import org.springframework.boot.test.mock.mockito.MockBean; |
|||
import org.springframework.boot.test.web.client.TestRestTemplate; |
|||
import org.springframework.context.annotation.Bean; |
|||
import org.springframework.context.annotation.Primary; |
|||
import org.springframework.data.redis.core.RedisTemplate; |
|||
import org.springframework.data.redis.core.ValueOperations; |
|||
import org.springframework.http.HttpEntity; |
|||
import org.springframework.http.HttpHeaders; |
|||
import org.springframework.http.HttpMethod; |
|||
import org.springframework.http.MediaType; |
|||
import org.springframework.http.ResponseEntity; |
|||
import org.springframework.jdbc.core.JdbcTemplate; |
|||
import org.springframework.mock.web.MockMultipartFile; |
|||
import org.springframework.test.context.ActiveProfiles; |
|||
import org.springframework.test.context.DynamicPropertyRegistry; |
|||
import org.springframework.test.context.DynamicPropertySource; |
|||
import org.springframework.test.annotation.DirtiesContext; |
|||
import org.testcontainers.containers.MySQLContainer; |
|||
import org.testcontainers.junit.jupiter.Container; |
|||
import org.testcontainers.junit.jupiter.Testcontainers; |
|||
|
|||
import java.nio.charset.StandardCharsets; |
|||
import java.time.Instant; |
|||
import java.util.ArrayList; |
|||
import java.util.List; |
|||
import java.util.Map; |
|||
import java.util.Set; |
|||
import java.util.concurrent.atomic.AtomicLong; |
|||
import java.util.concurrent.atomic.AtomicInteger; |
|||
|
|||
import static org.assertj.core.api.Assertions.assertThat; |
|||
import static org.mockito.ArgumentMatchers.anyString; |
|||
import static org.mockito.Mockito.mock; |
|||
import static org.mockito.Mockito.when; |
|||
|
|||
/** Phase B 黑盒:真实 MySQL 8、真实 HTTP socket 与正式 Security/Controller/Service 链路。 */ |
|||
@Testcontainers(disabledWithoutDocker = false) |
|||
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, |
|||
properties = { |
|||
"spring.rabbitmq.listener.simple.auto-startup=false", |
|||
"spring.rabbitmq.listener.direct.auto-startup=false" |
|||
}) |
|||
@ActiveProfiles("mysql-it") |
|||
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS) |
|||
class VerifiedCompanyGateMySqlBlackboxTest { |
|||
|
|||
private static final AtomicInteger RESUME_READS = new AtomicInteger(); |
|||
|
|||
@TestConfiguration |
|||
static class CountingResumeStorageConfiguration { |
|||
@Bean |
|||
@Primary |
|||
ResumeStorageService countingResumeStorageService(LocalResumeStorageService delegate) { |
|||
return new ResumeStorageService() { |
|||
@Override |
|||
public String store(Long userId, org.springframework.web.multipart.MultipartFile file) { |
|||
return delegate.store(userId, file); |
|||
} |
|||
|
|||
@Override |
|||
public byte[] read(String storagePath) { |
|||
RESUME_READS.incrementAndGet(); |
|||
return delegate.read(storagePath); |
|||
} |
|||
|
|||
@Override |
|||
public void delete(String storagePath) { |
|||
delegate.delete(storagePath); |
|||
} |
|||
}; |
|||
} |
|||
} |
|||
|
|||
private static final AtomicLong SEQUENCE = new AtomicLong(); |
|||
private static final List<String> SNAPSHOT_TABLES = List.of( |
|||
"users", "user_roles", "companies", "company_verifications", "jobs", "applications", |
|||
"student_profiles", "student_privacy_settings", "resume_versions", "notifications", |
|||
"audit_logs", "offers", "interviews", "messages", "auth_tokens"); |
|||
|
|||
@Container |
|||
static final MySQLContainer<?> MYSQL = new MySQLContainer<>("mysql:8.4") |
|||
.withDatabaseName("phase_b_gate_blackbox") |
|||
.withUsername("phase_b_gate_test") |
|||
.withPassword("phase-b-gate-test-only"); |
|||
|
|||
@DynamicPropertySource |
|||
static void datasource(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("spring.jpa.hibernate.ddl-auto", () -> "validate"); |
|||
registry.add("spring.rabbitmq.listener.simple.auto-startup", () -> "false"); |
|||
registry.add("spring.rabbitmq.listener.direct.auto-startup", () -> "false"); |
|||
} |
|||
|
|||
@Autowired private TestRestTemplate http; |
|||
@LocalServerPort private int port; |
|||
@Autowired private ObjectMapper objectMapper; |
|||
@Autowired private AuthSessionService authSessionService; |
|||
@Autowired private UserRepository userRepository; |
|||
@Autowired private CompanyRepository companyRepository; |
|||
@Autowired private CompanyVerificationRepository verificationRepository; |
|||
@Autowired private JobRepository jobRepository; |
|||
@Autowired private ApplicationRepository applicationRepository; |
|||
@Autowired private StudentProfileRepository profileRepository; |
|||
@Autowired private StudentPrivacySettingsRepository privacyRepository; |
|||
@Autowired private ResumeStorageService resumeStorageService; |
|||
@Autowired private JdbcTemplate jdbc; |
|||
@MockBean private RedisTemplate<String, Object> redisTemplate; |
|||
|
|||
private final List<Long> userIds = new ArrayList<>(); |
|||
private final List<Long> companyIds = new ArrayList<>(); |
|||
private final List<Long> jobIds = new ArrayList<>(); |
|||
private final List<Long> applicationIds = new ArrayList<>(); |
|||
private final List<String> storagePaths = new ArrayList<>(); |
|||
|
|||
@Test |
|||
@SuppressWarnings("unchecked") |
|||
void unverifiedPendingAndRejectedCompaniesAreDeniedOverHttpWithoutDatabaseSideEffects() throws Exception { |
|||
ValueOperations<String, Object> valueOperations = mock(ValueOperations.class); |
|||
when(redisTemplate.opsForValue()).thenReturn(valueOperations); |
|||
when(redisTemplate.hasKey(anyString())).thenReturn(false); |
|||
|
|||
for (String state : new String[]{"UNVERIFIED", "PENDING", "REJECTED"}) { |
|||
CompanyFixture fixture = company(state, false); |
|||
Job draft = job(fixture.companyId(), "DRAFT"); |
|||
List<Map<String, Object>> beforeRequest = snapshot(); |
|||
String body = "{\"status\":\"PUBLISHED\"}"; |
|||
ResponseEntity<String> response = exchange("/api/v1/jobs/" + draft.getId() + "/status", |
|||
HttpMethod.PATCH, fixture.token(), body); |
|||
assertGateDenied(response, fixture.phone()); |
|||
assertThat(jdbc.queryForObject("SELECT status FROM jobs WHERE id = ?", String.class, draft.getId())) |
|||
.as("拒绝后岗位状态保持不变:" + state).isEqualTo("DRAFT"); |
|||
assertThat(snapshot()).as("拒绝发布不应产生任何数据库写入:" + state).isEqualTo(beforeRequest); |
|||
} |
|||
|
|||
CompanyFixture pending = company("PENDING", false); |
|||
Long studentId = user("STUDENT", "黑盒候选人"); |
|||
Job published = job(pending.companyId(), "PUBLISHED"); |
|||
Long applicationId = applicationRepository.saveAndFlush(Application.builder() |
|||
.jobId(published.getId()).studentUserId(studentId).status("SUBMITTED").build()).getId(); |
|||
applicationIds.add(applicationId); |
|||
|
|||
MockMultipartFile resume = new MockMultipartFile("file", "phase-b-resume.pdf", "application/pdf", |
|||
"%PDF-1.4 phase-b-blackbox".getBytes(StandardCharsets.UTF_8)); |
|||
String storagePath = resumeStorageService.store(studentId, resume); |
|||
storagePaths.add(storagePath); |
|||
profileRepository.saveAndFlush(StudentProfile.builder() |
|||
.userId(studentId).resumeUrl(storagePath).resumeOriginalName("private-resume.pdf") |
|||
.resumeContentType("application/pdf").resumeSize(resume.getSize()).build()); |
|||
privacyRepository.saveAndFlush(StudentPrivacySettings.builder() |
|||
.studentUserId(studentId).resumeVisibility(ResumeVisibility.APPLIED_COMPANIES) |
|||
.contactVisible(false).version(0L).build()); |
|||
|
|||
// 若认证门禁被移除,APPLIED_COMPANIES 下的有效投递和有效文件会使下载返回 200。
|
|||
List<Map<String, Object>> before = snapshot(); |
|||
ResponseEntity<String> list = exchange("/api/v1/company/candidates", HttpMethod.GET, |
|||
pending.token(), null); |
|||
assertGateDenied(list, pending.phone()); |
|||
ResponseEntity<String> detail = exchange("/api/v1/company/candidates/" + applicationId, |
|||
HttpMethod.GET, pending.token(), null); |
|||
assertGateDenied(detail, pending.phone()); |
|||
RESUME_READS.set(0); |
|||
ResponseEntity<byte[]> download = http.exchange(url("/api/v1/company/candidates/" + applicationId + "/resume"), |
|||
HttpMethod.GET, authorized(pending.token(), null), byte[].class); |
|||
assertThat(download.getStatusCode().value()).isEqualTo(403); |
|||
assertThat(download.getHeaders().getContentType()).isNotEqualTo(MediaType.APPLICATION_PDF); |
|||
assertThat(RESUME_READS.get()).as("认证拒绝必须早于真实文件读取").isZero(); |
|||
assertThat(snapshot()).as("认证拒绝路径数据库逐表快照必须零变化").isEqualTo(before); |
|||
} |
|||
|
|||
@Test |
|||
void verifiedCompanyCanPublishOwnJobButCannotProbeAnotherCompaniesJob() throws Exception { |
|||
CompanyFixture companyA = company("VERIFIED_A", true); |
|||
CompanyFixture companyB = company("VERIFIED_B", true); |
|||
Job ownDraft = job(companyA.companyId(), "DRAFT"); |
|||
Job foreignDraft = job(companyB.companyId(), "DRAFT"); |
|||
|
|||
ResponseEntity<String> publish = exchange("/api/v1/jobs/" + ownDraft.getId() + "/status", |
|||
HttpMethod.PATCH, companyA.token(), "{\"status\":\"PUBLISHED\"}"); |
|||
assertThat(publish.getStatusCode().value()).isEqualTo(200); |
|||
assertThat(jdbc.queryForObject("SELECT status FROM jobs WHERE id = ?", String.class, ownDraft.getId())) |
|||
.isEqualTo("PUBLISHED"); |
|||
|
|||
List<Map<String, Object>> before = snapshot(); |
|||
Long missingId = foreignDraft.getId() + 900_000L; |
|||
for (String method : new String[]{"GET", "PUT"}) { |
|||
ResponseEntity<String> foreign = exchange("/api/v1/jobs/" + foreignDraft.getId(), |
|||
HttpMethod.valueOf(method), companyA.token(), method.equals("PUT") |
|||
? "{\"title\":\"attempted cross-tenant update\"}" : null); |
|||
ResponseEntity<String> missing = exchange("/api/v1/jobs/" + missingId, |
|||
HttpMethod.valueOf(method), companyA.token(), method.equals("PUT") |
|||
? "{\"title\":\"attempted missing update\"}" : null); |
|||
assertThat(foreign.getStatusCode().value()).isEqualTo(404); |
|||
assertThat(missing.getStatusCode().value()).isEqualTo(404); |
|||
assertThat(code(foreign)).isEqualTo(ErrorCode.JOB_NOT_FOUND.getCode()); |
|||
assertThat(code(missing)).isEqualTo(ErrorCode.JOB_NOT_FOUND.getCode()); |
|||
} |
|||
ResponseEntity<String> foreignPublish = exchange("/api/v1/jobs/" + foreignDraft.getId() + "/status", |
|||
HttpMethod.PATCH, companyA.token(), "{\"status\":\"PUBLISHED\"}"); |
|||
assertThat(foreignPublish.getStatusCode().value()).isEqualTo(404); |
|||
assertThat(code(foreignPublish)).isEqualTo(ErrorCode.JOB_NOT_FOUND.getCode()); |
|||
assertThat(snapshot()).as("跨企业拒绝路径数据库逐表快照必须零变化").isEqualTo(before); |
|||
assertThat(jdbc.queryForObject("SELECT status FROM jobs WHERE id = ?", String.class, foreignDraft.getId())) |
|||
.isEqualTo("DRAFT"); |
|||
} |
|||
|
|||
private CompanyFixture company(String verificationState, boolean verified) { |
|||
Long userId = user("COMPANY", "Phase B " + verificationState); |
|||
Company company = companyRepository.saveAndFlush(Company.builder() |
|||
.ownerUserId(userId).name("Phase B " + verificationState).verified(verified).build()); |
|||
companyIds.add(company.getId()); |
|||
if (!"UNVERIFIED".equals(verificationState)) { |
|||
verificationRepository.saveAndFlush(CompanyVerification.builder() |
|||
.companyId(company.getId()).submittedBy(userId) |
|||
.status(verified ? "APPROVED" : verificationState).build()); |
|||
} |
|||
User persisted = userRepository.findById(userId).orElseThrow(); |
|||
String token = authSessionService.createSession(persisted, Instant.now(), null).accessToken(); |
|||
return new CompanyFixture(userId, company.getId(), persisted.getPhone(), "Bearer " + token); |
|||
} |
|||
|
|||
private Long user(String roleName, String nickname) { |
|||
User.Role role = User.Role.valueOf(roleName); |
|||
String phone = "199" + String.format("%08d", SEQUENCE.incrementAndGet()); |
|||
User saved = userRepository.saveAndFlush(User.builder().phone(phone).nickname(nickname) |
|||
.role(role).roles(Set.of(role)).active(true) |
|||
// users.is_verified is deliberately true; it is not the company gate authority.
|
|||
.verified(true).build()); |
|||
userIds.add(saved.getId()); |
|||
return saved.getId(); |
|||
} |
|||
|
|||
private Job job(Long companyId, String status) { |
|||
Job saved = jobRepository.saveAndFlush(Job.builder().companyId(companyId) |
|||
.title("Phase B blackbox job").description("fixture").city("Shanghai") |
|||
.employmentType("FULL_TIME").status(status).build()); |
|||
jobIds.add(saved.getId()); |
|||
return saved; |
|||
} |
|||
|
|||
private ResponseEntity<String> exchange(String path, HttpMethod method, String token, String body) { |
|||
HttpHeaders headers = new HttpHeaders(); |
|||
headers.set(HttpHeaders.AUTHORIZATION, token); |
|||
if (body != null) headers.setContentType(MediaType.APPLICATION_JSON); |
|||
return http.exchange(url(path), method, new HttpEntity<>(body, headers), String.class); |
|||
} |
|||
|
|||
private String url(String path) { |
|||
return "http://127.0.0.1:" + port + path; |
|||
} |
|||
|
|||
private HttpEntity<String> authorized(String token, String body) { |
|||
HttpHeaders headers = new HttpHeaders(); |
|||
headers.set(HttpHeaders.AUTHORIZATION, token); |
|||
return new HttpEntity<>(body, headers); |
|||
} |
|||
|
|||
private void assertGateDenied(ResponseEntity<String> response, String phone) throws Exception { |
|||
assertThat(response.getStatusCode().value()).isEqualTo(403); |
|||
JsonNode body = objectMapper.readTree(response.getBody()); |
|||
assertThat(body.path("code").asInt()).isEqualTo(ErrorCode.COMPANY_NOT_VERIFIED.getCode()); |
|||
assertThat(body.path("message").asText()).isEqualTo(ErrorCode.COMPANY_NOT_VERIFIED.getMessage()); |
|||
String serialized = response.getBody() == null ? "" : response.getBody(); |
|||
assertThat(serialized).doesNotContain(phone, "resumeUrl", "storagePath", "SQLException", "SQLState", "at com."); |
|||
} |
|||
|
|||
private int code(ResponseEntity<String> response) throws Exception { |
|||
JsonNode body = objectMapper.readTree(response.getBody()); |
|||
return body.path("code").asInt(); |
|||
} |
|||
|
|||
private List<Map<String, Object>> snapshot() { |
|||
List<Map<String, Object>> rows = new ArrayList<>(); |
|||
for (String table : SNAPSHOT_TABLES) { |
|||
rows.add(Map.of("table", table, "rows", jdbc.queryForList("SELECT * FROM " + table + " ORDER BY 1"))); |
|||
} |
|||
return rows; |
|||
} |
|||
|
|||
@AfterEach |
|||
void cleanupExactFixtures() { |
|||
storagePaths.forEach(resumeStorageService::delete); |
|||
for (Long id : applicationIds) { |
|||
jdbc.update("DELETE FROM student_privacy_settings WHERE student_user_id IN " + |
|||
"(SELECT student_user_id FROM applications WHERE id = ?)", id); |
|||
jdbc.update("DELETE FROM applications WHERE id = ?", id); |
|||
} |
|||
for (Long jobId : jobIds) jdbc.update("DELETE FROM jobs WHERE id = ?", jobId); |
|||
if (!companyIds.isEmpty()) { |
|||
for (Long companyId : companyIds) { |
|||
jdbc.update("DELETE FROM company_verifications WHERE company_id = ?", companyId); |
|||
jdbc.update("DELETE FROM companies WHERE id = ?", companyId); |
|||
} |
|||
} |
|||
for (Long userId : userIds) { |
|||
jdbc.update("DELETE FROM student_profiles WHERE user_id = ?", userId); |
|||
jdbc.update("DELETE FROM student_privacy_settings WHERE student_user_id = ?", userId); |
|||
jdbc.update("DELETE FROM auth_tokens WHERE user_id = ?", userId); |
|||
jdbc.update("DELETE FROM user_roles WHERE user_id = ?", userId); |
|||
jdbc.update("DELETE FROM users WHERE id = ?", userId); |
|||
} |
|||
userIds.clear(); companyIds.clear(); jobIds.clear(); applicationIds.clear(); storagePaths.clear(); |
|||
} |
|||
|
|||
private record CompanyFixture(Long userId, Long companyId, String phone, String token) {} |
|||
} |
|||
@ -0,0 +1,193 @@ |
|||
package com.mydreamjob.phaseb; |
|||
|
|||
import com.mydreamjob.auth.AuthSessionService; |
|||
import com.mydreamjob.auth.User; |
|||
import com.mydreamjob.auth.UserRepository; |
|||
import com.fasterxml.jackson.databind.ObjectMapper; |
|||
import com.mydreamjob.common.exception.BusinessException; |
|||
import com.mydreamjob.common.exception.ErrorCode; |
|||
import com.mydreamjob.company.Company; |
|||
import com.mydreamjob.company.CompanyRepository; |
|||
import com.mydreamjob.job.Job; |
|||
import com.mydreamjob.job.JobRepository; |
|||
import com.mydreamjob.job.JobService; |
|||
import com.mydreamjob.job.dto.JobStatusRequest; |
|||
import com.mydreamjob.verification.CompanyVerification; |
|||
import com.mydreamjob.verification.CompanyVerificationRepository; |
|||
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.test.context.ActiveProfiles; |
|||
import org.springframework.test.web.servlet.MockMvc; |
|||
import org.springframework.test.web.servlet.MvcResult; |
|||
import org.springframework.transaction.annotation.Transactional; |
|||
|
|||
import java.util.Set; |
|||
import java.util.concurrent.atomic.AtomicInteger; |
|||
import java.time.Instant; |
|||
|
|||
import static org.junit.jupiter.api.Assertions.assertEquals; |
|||
import static org.junit.jupiter.api.Assertions.assertThrows; |
|||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; |
|||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; |
|||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; |
|||
|
|||
/** Real JPA rows prove verification requests do not substitute for companies.verified. */ |
|||
@SpringBootTest |
|||
@AutoConfigureMockMvc |
|||
@ActiveProfiles("integration-test") |
|||
@Transactional |
|||
class VerifiedCompanyGatePersistenceIntegrationTest { |
|||
|
|||
private static final AtomicInteger PHONE_SEQUENCE = new AtomicInteger(); |
|||
|
|||
@Autowired private UserRepository userRepository; |
|||
@Autowired private AuthSessionService authSessionService; |
|||
@Autowired private ObjectMapper objectMapper; |
|||
@Autowired private CompanyRepository companyRepository; |
|||
@Autowired private CompanyVerificationRepository verificationRepository; |
|||
@Autowired private JobRepository jobRepository; |
|||
@Autowired private JobService jobService; |
|||
@Autowired private MockMvc mockMvc; |
|||
@MockBean private RedisTemplate<String, Object> redisTemplate; |
|||
|
|||
@Test |
|||
void unverifiedPendingAndRejectedApplicationsDoNotEnableJobPublication() { |
|||
for (String applicationStatus : new String[]{null, "PENDING", "REJECTED"}) { |
|||
String stateLabel = applicationStatus == null ? "UNVERIFIED" : applicationStatus; |
|||
Long ownerUserId = createCompanyOwner(); |
|||
Company company = companyRepository.saveAndFlush(Company.builder() |
|||
.ownerUserId(ownerUserId).name("Phase B fixture " + stateLabel).verified(false).build()); |
|||
if (applicationStatus != null) { |
|||
verificationRepository.saveAndFlush(CompanyVerification.builder() |
|||
.companyId(company.getId()).submittedBy(ownerUserId).status(applicationStatus).build()); |
|||
} |
|||
Job draft = jobRepository.saveAndFlush(Job.builder() |
|||
.companyId(company.getId()).title("Draft " + stateLabel) |
|||
.description("Phase B persistence fixture") |
|||
.city("Shanghai").employmentType("FULL_TIME").status("DRAFT").build()); |
|||
|
|||
try { |
|||
mockMvc.perform(patch("/jobs/{jobId}/status", draft.getId()) |
|||
.header("Authorization", bearerFor(ownerUserId)) |
|||
.contentType(MediaType.APPLICATION_JSON) |
|||
.content("{\"status\":\"PUBLISHED\"}")) |
|||
.andExpect(status().isForbidden()) |
|||
.andExpect(jsonPath("$.code").value(ErrorCode.COMPANY_NOT_VERIFIED.getCode())) |
|||
.andExpect(jsonPath("$.message").value(ErrorCode.COMPANY_NOT_VERIFIED.getMessage())); |
|||
} catch (Exception exception) { |
|||
throw new AssertionError("unverified company publish request failed unexpectedly", exception); |
|||
} |
|||
assertEquals("DRAFT", jobRepository.findById(draft.getId()).orElseThrow().getStatus()); |
|||
} |
|||
} |
|||
|
|||
@Test |
|||
void verifiedCompanyCanPublishWithPersistedApprovedRecord() { |
|||
Long ownerUserId = createCompanyOwner(); |
|||
Company company = companyRepository.saveAndFlush(Company.builder() |
|||
.ownerUserId(ownerUserId).name("Phase B verified fixture").verified(true).build()); |
|||
verificationRepository.saveAndFlush(CompanyVerification.builder() |
|||
.companyId(company.getId()).submittedBy(ownerUserId).status("APPROVED").build()); |
|||
Job draft = jobRepository.saveAndFlush(Job.builder() |
|||
.companyId(company.getId()).title("Verified draft") |
|||
.description("Phase B persistence fixture") |
|||
.city("Shanghai").employmentType("FULL_TIME").status("DRAFT").build()); |
|||
|
|||
Job published = jobService.transitionStatus(company.getId(), draft.getId(), publishRequest()); |
|||
|
|||
assertEquals("PUBLISHED", published.getStatus()); |
|||
} |
|||
|
|||
@Test |
|||
void unverifiedCompanyCannotProbeJobIdsWhenPublishing() throws Exception { |
|||
Long ownerUserId = createCompanyOwner(); |
|||
companyRepository.saveAndFlush(Company.builder() |
|||
.ownerUserId(ownerUserId).name("Phase B owner fixture").verified(false).build()); |
|||
|
|||
Long otherOwnerUserId = createCompanyOwner(); |
|||
Company otherCompany = companyRepository.saveAndFlush(Company.builder() |
|||
.ownerUserId(otherOwnerUserId).name("Phase B other fixture").verified(true).build()); |
|||
Job foreignDraft = jobRepository.saveAndFlush(Job.builder() |
|||
.companyId(otherCompany.getId()).title("Foreign draft") |
|||
.description("Phase B persistence fixture") |
|||
.city("Shanghai").employmentType("FULL_TIME").status("DRAFT").build()); |
|||
|
|||
String authorization = bearerFor(ownerUserId); |
|||
for (Long jobId : new Long[]{foreignDraft.getId(), foreignDraft.getId() + 100_000L}) { |
|||
MvcResult result = mockMvc.perform(patch("/jobs/{jobId}/status", jobId) |
|||
.header("Authorization", authorization) |
|||
.contentType(MediaType.APPLICATION_JSON) |
|||
.content("{\"status\":\"PUBLISHED\"}")) |
|||
.andReturn(); |
|||
assertEquals(403, result.getResponse().getStatus()); |
|||
assertEquals(ErrorCode.COMPANY_NOT_VERIFIED.getCode(), |
|||
objectMapper.readTree(result.getResponse().getContentAsString()).path("code").asInt()); |
|||
} |
|||
|
|||
assertEquals("DRAFT", jobRepository.findById(foreignDraft.getId()).orElseThrow().getStatus()); |
|||
} |
|||
|
|||
@Test |
|||
void companyJobReadAndUpdateDoNotDistinguishForeignFromMissingJobs() throws Exception { |
|||
Long ownerUserId = createCompanyOwner(); |
|||
companyRepository.saveAndFlush(Company.builder() |
|||
.ownerUserId(ownerUserId).name("Phase B owner fixture").verified(false).build()); |
|||
Long otherOwnerUserId = createCompanyOwner(); |
|||
Company otherCompany = companyRepository.saveAndFlush(Company.builder() |
|||
.ownerUserId(otherOwnerUserId).name("Phase B other fixture").verified(true).build()); |
|||
Job foreignDraft = jobRepository.saveAndFlush(Job.builder() |
|||
.companyId(otherCompany.getId()).title("Foreign draft") |
|||
.description("Phase B persistence fixture") |
|||
.city("Shanghai").employmentType("FULL_TIME").status("DRAFT").build()); |
|||
|
|||
String authorization = bearerFor(ownerUserId); |
|||
Long missingJobId = foreignDraft.getId() + 100_000L; |
|||
for (String method : new String[]{"GET", "PUT"}) { |
|||
for (Long jobId : new Long[]{foreignDraft.getId(), missingJobId}) { |
|||
var request = method.equals("GET") |
|||
? org.springframework.test.web.servlet.request.MockMvcRequestBuilders |
|||
.get("/jobs/{jobId}", jobId) |
|||
: org.springframework.test.web.servlet.request.MockMvcRequestBuilders |
|||
.put("/jobs/{jobId}", jobId) |
|||
.contentType(MediaType.APPLICATION_JSON) |
|||
.content("{\"title\":\"Updated title\"}"); |
|||
MvcResult result = mockMvc.perform(request.header("Authorization", authorization)).andReturn(); |
|||
assertEquals(404, result.getResponse().getStatus()); |
|||
assertEquals(ErrorCode.JOB_NOT_FOUND.getCode(), |
|||
objectMapper.readTree(result.getResponse().getContentAsString()).path("code").asInt()); |
|||
} |
|||
} |
|||
assertEquals("Foreign draft", jobRepository.findById(foreignDraft.getId()).orElseThrow().getTitle()); |
|||
} |
|||
|
|||
private Long createCompanyOwner() { |
|||
int suffix = PHONE_SEQUENCE.incrementAndGet(); |
|||
User user = User.builder() |
|||
.phone("00000000" + String.format("%03d", suffix)) |
|||
.nickname("Phase B fixture") |
|||
.role(User.Role.COMPANY) |
|||
.roles(Set.of(User.Role.COMPANY)) |
|||
.active(true) |
|||
// Deliberately true: users.is_verified is not enterprise verification authority.
|
|||
.verified(true) |
|||
.build(); |
|||
return userRepository.saveAndFlush(user).getId(); |
|||
} |
|||
|
|||
private JobStatusRequest publishRequest() { |
|||
JobStatusRequest request = new JobStatusRequest(); |
|||
request.setStatus("PUBLISHED"); |
|||
return request; |
|||
} |
|||
|
|||
private String bearerFor(Long userId) { |
|||
String token = authSessionService.createSession( |
|||
userRepository.findById(userId).orElseThrow(), Instant.now(), null).accessToken(); |
|||
return "Bearer " + token; |
|||
} |
|||
} |
|||
@ -0,0 +1,49 @@ |
|||
package com.mydreamjob.phaseb; |
|||
|
|||
import com.mydreamjob.company.Company; |
|||
import com.mydreamjob.company.VerifiedCompanyGuard; |
|||
import com.mydreamjob.common.exception.BusinessException; |
|||
import com.mydreamjob.common.exception.ErrorCode; |
|||
import com.mydreamjob.common.exception.ErrorHttpStatusRegistry; |
|||
import org.junit.jupiter.api.Test; |
|||
import org.springframework.http.HttpStatus; |
|||
|
|||
import static org.assertj.core.api.Assertions.assertThat; |
|||
import static org.assertj.core.api.Assertions.assertThatThrownBy; |
|||
|
|||
class VerifiedCompanyGuardTest { |
|||
|
|||
private final ErrorHttpStatusRegistry statuses = new ErrorHttpStatusRegistry(); |
|||
|
|||
@Test |
|||
void verifiedCompanyPassesWithoutMutatingTheCompany() { |
|||
Company company = Company.builder().id(41L).ownerUserId(72L).verified(true).build(); |
|||
|
|||
assertThat(VerifiedCompanyGuard.requireVerified(company)).isSameAs(company); |
|||
assertThat(company.getVerified()).isTrue(); |
|||
} |
|||
|
|||
@Test |
|||
void unverifiedCompanyGetsOneStableForbiddenBusinessError() { |
|||
for (Company company : new Company[]{ |
|||
Company.builder().verified(false).build(), |
|||
Company.builder().verified(null).build()}) { |
|||
assertThatThrownBy(() -> VerifiedCompanyGuard.requireVerified(company)) |
|||
.isInstanceOf(BusinessException.class) |
|||
.satisfies(error -> { |
|||
BusinessException business = (BusinessException) error; |
|||
assertThat(business.getErrorCodeInt()).isEqualTo(ErrorCode.COMPANY_NOT_VERIFIED.getCode()); |
|||
assertThat(business.getErrorMessage()).isEqualTo(ErrorCode.COMPANY_NOT_VERIFIED.getMessage()); |
|||
assertThat(statuses.resolve(business.getErrorCodeInt())).isEqualTo(HttpStatus.FORBIDDEN); |
|||
}); |
|||
} |
|||
} |
|||
|
|||
@Test |
|||
void missingCompanyDoesNotBecomeVerified() { |
|||
assertThatThrownBy(() -> VerifiedCompanyGuard.requireVerified(null)) |
|||
.isInstanceOf(BusinessException.class) |
|||
.satisfies(error -> assertThat(((BusinessException) error).getErrorCodeInt()) |
|||
.isEqualTo(ErrorCode.COMPANY_NOT_FOUND.getCode())); |
|||
} |
|||
} |
|||
Loading…
Reference in new issue