Browse Source

feat(auth): 支持验证码登录后首次设置密码

release/identity-selection-20260929
Zhang Shun 2 weeks ago
parent
commit
439c25be9c
  1. 73
      docs/first-password-setup-implementation.md
  2. 25
      docs/openapi-rc.json
  3. 10
      docs/verify_openapi.py
  4. 10
      mydreamjob-server/src/main/java/com/mydreamjob/auth/AuthService.java
  5. 2
      mydreamjob-server/src/main/java/com/mydreamjob/auth/AuthSessionService.java
  6. 5
      mydreamjob-server/src/main/java/com/mydreamjob/auth/AuthTokenRepository.java
  7. 11
      mydreamjob-server/src/main/java/com/mydreamjob/auth/dto/PasswordSetupRequest.java
  8. 6
      mydreamjob-server/src/main/java/com/mydreamjob/auth/dto/PasswordSetupResponse.java
  9. 2
      mydreamjob-server/src/main/java/com/mydreamjob/auth/identity/IdentityAccessFilter.java
  10. 4
      mydreamjob-server/src/main/java/com/mydreamjob/auth/password/PasswordAuthenticationService.java
  11. 18
      mydreamjob-server/src/main/java/com/mydreamjob/auth/password/PasswordSetupChallenge.java
  12. 16
      mydreamjob-server/src/main/java/com/mydreamjob/auth/password/PasswordSetupChallengeRepository.java
  13. 73
      mydreamjob-server/src/main/java/com/mydreamjob/auth/password/PasswordSetupService.java
  14. 1
      mydreamjob-server/src/main/java/com/mydreamjob/config/SecurityConfig.java
  15. 27
      mydreamjob-server/src/main/java/com/mydreamjob/controller/AuthController.java
  16. 11
      mydreamjob-server/src/main/resources/db/migration/V54__password_setup_challenges.sql
  17. 4
      mydreamjob-server/src/test/java/com/mydreamjob/application/CompanyResumeDownloadIntegrationTest.java
  18. 172
      mydreamjob-server/src/test/java/com/mydreamjob/auth/AuthPasswordSetupIntegrationTest.java
  19. 20
      mydreamjob-server/src/test/java/com/mydreamjob/auth/AuthPasswordSetupMySqlTest.java
  20. 2
      mydreamjob-server/src/test/java/com/mydreamjob/auth/AuthServiceTest.java
  21. 8
      mydreamjob-server/src/test/java/com/mydreamjob/auth/identity/UnifiedIdentityJarAcceptanceProbe.java
  22. 2
      mydreamjob-server/src/test/java/com/mydreamjob/auth/identity/UnifiedRegistrationIntegrationTest.java
  23. 2
      mydreamjob-server/src/test/java/com/mydreamjob/config/MySql57MigrationConfigurationTest.java
  24. 4
      mydreamjob-server/src/test/java/com/mydreamjob/integration/LxqModulesMySql57IntegrationTest.java
  25. 4
      mydreamjob-server/src/test/java/com/mydreamjob/integration/LxqModulesMySqlIntegrationTest.java

73
docs/first-password-setup-implementation.md

@ -0,0 +1,73 @@
# 短信登录后首次设置密码:实施与联调指令
本批面向后端与前端开发人员,实现首次设置引导。设置密码后,短信与密码登录继续并存;不新增必须设置密码才能使用业务的门禁。
## 1. 登录与密码状态
在 POST /auth/login、POST /auth/password/login 和 GET /auth/me 中读取 passwordConfigured。它由 password_hash 是否存在派生,不增加 users 状态列。
无密码账号只有在 login 用途短信验证成功后,才获得 passwordSetupToken 和 passwordSetupExpiresIn=600。凭证不是 JWT;使用安全随机数生成 32 字节,数据库只保存带 password-setup 域前缀的 HMAC 摘要。
凭证绑定 userId、sessionId 和固定到期时间。密码登录、刷新、身份选择都不会签发设置凭证;刷新不能延长有效期。当前会话退出或被撤销后,凭证不可使用。
## 2. 请求与响应
前置地址为既有 /api/v1。新接口 POST /auth/password/setup 必须携带当前 Access Token。
```json
{"passwordSetupToken":"短信登录返回的一次性凭证","newPassword":"用户输入的新密码"}
```
成功返回现有 ApiResponse 包装,data 含 passwordConfigured=true、reauthenticationRequired=false、identitySelectionRequired、accessToken、refreshToken、expiresIn。有效期沿用既有认证配置与契约,不写死为一小时。
前端应先替换 Access/Refresh Token 并清除设置凭证,再根据 identitySelectionRequired 进入身份选择或原角色首页。已有密码不重复弹设置页。凭证过期或响应丢失时,重新短信登录并读取密码状态;不得反复提交创建操作。
参数/密码策略错误为 400,无效会话或设置凭证为 401,已有密码冲突为 409,限流为 429,安全依赖不可用为 503。不得把 SQL、密码、令牌或凭证摘要返回给客户端。
## 3. 后端事务顺序
新增 V54 password_setup_challenges,不修改 V1–V53。核查 MySQL 5.7/8.4 和 H2 的兼容性。
先锁定用户并校验 active/注销状态,再锁定当前服务端会话记录,串行处理并发登出;检查操作限流与尚无密码,复用现有密码策略。数据库条件更新必须同时匹配用户、会话、摘要、未消费、未过期,且更新行数恰好为 1。
随后 BCrypt 保存密码及 passwordChangedAt,flush 用户,撤销全部旧会话,建立新会话,并写固定、脱敏审计。上述操作使用同一数据库事务;任何步骤失败均回滚。Redis 限流计数不宣称自动回滚。
未选身份账号只新增放行 POST /auth/password/setup,继续保留其他业务门禁。客户端不传 userId、role 或 sessionId。
## 4. 兼容与库存
保留 /auth/password/set 的 password-set 短信用途与成功后重新认证语义;保留修改、找回密码及原有登录、刷新、登出接口。设置成功不能覆盖企业认证或账号角色。
静态 OpenAPI 与运行时均增加一个路径、一个操作,目标 198 路径、241 操作。接口库存测试随真实新增接口更新;不降低原有身份门禁断言。
## 5. 验收步骤
用明确假号段准备无密码账号,经真实验证码消费逻辑短信登录,检查未设置状态和凭证有效期。首次设置后检查 BCrypt 匹配、新会话可用、旧 Access/Refresh Token 被撤销;再次短信登录不发设置凭证,密码登录也应成功。
测试跨用户、跨会话、匿名、过期、已消费、弱密码和安全依赖异常。两个并发请求同时使用凭证,只允许一个成功;审计或会话创建失败应回滚密码、消费状态、会话变更。
同一套断言在 H2 和真实 MySQL 8.4 执行,并运行 MySQL 5.7 迁移及认证关联回归。记录真实 F/E/S 与 OpenAPI 结果后再安排独立复验、提交和部署。
当前文档描述本地实现;测试结果以本轮实际报告为准,不表示已上线或已完成前端接入。
## 6. 本轮验证结果与交付边界
2026-10-12:认证关联回归执行 68 项,Failures=0、Errors=0、Skipped=0,覆盖旧密码接口、刷新、会话、统一注册、企业注册,以及真实 MySQL 5.7/8.4 迁移和运行时接口库存。
最终补充数据库异常安全响应与 DTO 脱敏后,重新编译最新源码并执行 H2 和真实 MySQL 8.4 的首次设置密码测试,共 20 项,Failures=0、Errors=0、Skipped=0;定向 mvn verify 成功并生成最新 JAR。两组测试存在重叠,不合计为 88 个独立用例。
静态 OpenAPI 检查通过:198 路径、241 操作。历史 V1–V53 无内容修改;新增 V54。git diff --check 通过,暂存区为空。
2026-10-12 补充全量与最新 JAR 验收:`mvn -o clean verify` 成功,3278 项,Failures=0、Errors=0、Skipped=0。首次全量发现 MySql57MigrationConfigurationTest 仍断言 53 个迁移,与新增 V54 不一致;仅更新计数为 54,定向 4/4 通过后重跑全量成功。全量日志位于本机临时目录 password-setup-full-verify-final.log。
验收对象为上述 clean verify 生成的 mydreamjob-server-0.0.1-SNAPSHOT.jar,SHA256:`635EB9F268771D266EED8965FCD39F03F9B24E8B7219945980FB54037D303822`。
使用独立、一次性 MySQL 8.4、Redis 7.4、RabbitMQ 3.13 容器与该 JAR 做真实 HTTP 黑盒,应用及管理健康检查均为 200,Flyway 达到 V54。使用假号段测试账号,短信发送通过 Mock sender,但验证码生成、数据库消费、Redis 限流及认证会话均执行真实实现;验证码从隔离数据库读取,没有调用运营商。
黑盒通过:无密码账号短信登录返回首次设置凭证;弱密码 400、匿名设置 401;设置成功返回新会话且无需重新登录;旧 Access 与 Refresh 均 401,新 Access 可用;再次使用设置凭证 409;设置后选择 STUDENT 成功;密码登录成功;再次发送验证码并短信登录成功,返回 passwordConfigured=true 且不再返回设置凭证。
数据库逐项证据:测试用户 1 行、STUDENT 主角色与 user_roles 各 1 条、BCrypt 格式密码与 password_changed_at 均已保存;设置凭证 1 条且已消费;两条短信验证码均已消费。四个 RabbitMQ 队列(短信、通知、审计、通知死信)ready/unacknowledged 全为 0。应用日志扫描未发现测试手机号、明文测试密码或 JWT/Bearer。
静态及运行时 OpenAPI 均为 198 路径、241 操作,方法/路径集合完全一致。黑盒脚本最初使用 PowerShell 属性集合的 .Count 导致误报,改用数组计数复核后通过。首次 JAR 启动遇到默认管理端口 8081 与前端 Metro 冲突,将本次管理端口设为 18081 后启动成功;应用端口为 18080。
验收后已停止本次 JAR,并删除三个一次性容器及其测试数据;13307、16379、25672、18080、18081 无本次残留监听。前端 Metro 保持运行。本批未暂存、提交、推送或部署;真实短信与前端设置密码页面联调仍未执行,不代表线上已有此接口。

25
docs/openapi-rc.json

@ -1436,7 +1436,7 @@
"认证"
],
"summary": "手机号验证码登录",
"description": "手机号验证码登录;新手机号创建 UNASSIGNED 统一账号,identitySelectionRequired=true 时前端必须进入首次身份选择;旧角色保持不变。保留原字段及成功码 200。",
"description": "手机号验证码登录;返回 passwordConfigured。无密码且密码功能开启时,login 短信验证成功还返回一次性的 passwordSetupToken 与 passwordSetupExpiresIn=600;设置成功替换 Token 后根据 identitySelectionRequired 选择身份。旧角色、验证码登录及成功码 200 保持不变。",
"operationId": "mockLogin",
"requestBody": {
"content": {
@ -9406,6 +9406,17 @@
}
}
},
"/auth/password/setup": {
"post": {
"tags": ["认证"],
"summary": "短信登录后首次设置密码",
"description": "必须携带当前 Access Token 与 login 登录返回的 10 分钟一次性 passwordSetupToken;成功后替换两枚 Token,验证码登录继续可用。",
"operationId": "setupPassword",
"security": [{"bearerAuth": []}],
"requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/PasswordSetupRequest"}}}},
"responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponsePasswordSetupResponse"}}}}, "400": {"description": "参数或密码策略错误"}, "401": {"description": "会话或设置凭证无效"}, "409": {"description": "密码已设置"}, "429": {"description": "操作限流"}, "503": {"description": "安全依赖不可用"}}
}
},
"/auth/password/set": {
"post": {
"tags": [
@ -18298,6 +18309,18 @@
"newPassword"
]
},
"PasswordSetupRequest": {
"type": "object", "required": ["passwordSetupToken", "newPassword"],
"properties": {"passwordSetupToken": {"type": "string", "maxLength": 512, "writeOnly": true}, "newPassword": {"type": "string", "maxLength": 256, "writeOnly": true}}
},
"PasswordSetupResponse": {
"type": "object",
"properties": {"passwordConfigured": {"type": "boolean"}, "reauthenticationRequired": {"type": "boolean"}, "identitySelectionRequired": {"type": "boolean"}, "accessToken": {"type": "string"}, "refreshToken": {"type": "string"}, "expiresIn": {"type": "integer", "format": "int64"}}
},
"ApiResponsePasswordSetupResponse": {
"type": "object",
"properties": {"code": {"type": "integer", "format": "int32"}, "message": {"type": "string"}, "data": {"$ref": "#/components/schemas/PasswordSetupResponse"}, "requestId": {"type": "string"}, "timestamp": {"type": "string", "format": "date-time"}}
},
"PasswordOperationResponse": {
"type": "object",
"properties": {

10
docs/verify_openapi.py

@ -6,7 +6,7 @@ p = PROJECT_ROOT / 'docs' / 'openapi-rc.json'
d = json.load(open(p, encoding='utf-8'))
print('paths:', len(d['paths']))
assert len(d['paths']) == 197, f"Expected 197 paths (184 baseline + 13 module paths), got {len(d['paths'])}"
assert len(d['paths']) == 198, f"Expected 198 paths (185 baseline + 13 module paths), got {len(d['paths'])}"
paths = d['paths']
@ -649,7 +649,7 @@ assert _auth_paths == {
'/auth/sms/send', '/auth/login', '/auth/me',
'/auth/refresh', '/auth/logout',
'/auth/password/login', '/auth/password/set', '/auth/password/change', '/auth/password/reset',
'/auth/company/register', '/auth/identity/select',
'/auth/company/register', '/auth/identity/select', '/auth/password/setup',
}, f'Unexpected /auth paths: {sorted(_auth_paths)}'
print(' S02 refresh/logout and password authentication operations validated')
@ -716,10 +716,12 @@ for _p in paths:
assert not _p.startswith('/actuator'), f'Actuator must not appear in the business contract: {_p}'
print(' S03 company resume download contract validated; candidate DTOs remain redacted')
# 统计 operation 总数并校验当前基线 222 个既有 operation 加 18 个模块 operation。
# 统计 operation 总数并校验当前基线 223 个既有 operation 加 18 个模块 operation。
operations = sum(len(methods) for methods in d['paths'].values())
print('operations:', operations)
assert operations == 240, f"Expected 240 operations (222 baseline + 18 module operations), got {operations}"
assert operations == 241, f"Expected 241 operations (223 baseline + 18 module operations), got {operations}"
assert paths['/auth/password/setup']['post']['operationId'] == 'setupPassword'
assert 'bearerAuth' in json.dumps(paths['/auth/password/setup']['post']['security'])
# 统一账号身份选择:只允许两种业务身份,保持旧登录字段引用。
_select = paths['/auth/identity/select']['post']

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

@ -38,6 +38,7 @@ public class AuthService {
private final AccountDeletionPhoneHasher accountDeletionPhoneHasher;
private final AuthSessionService authSessionService;
private final Clock clock;
private final com.mydreamjob.auth.password.PasswordSetupService passwordSetupService;
/**
* Mock 登录:验证短信验证码后自动创建/登录用户,并建立一次登录会话。
@ -99,6 +100,14 @@ public class AuthService {
result.put("nickname", user.getNickname());
result.put("role", user.getRole());
result.put("identitySelectionRequired", user.getRole() == User.Role.UNASSIGNED);
result.put("passwordConfigured", user.getPasswordHash() != null);
if ("login".equals(purpose) && user.getPasswordHash() == null) {
String setupProof = passwordSetupService.issue(user, tokens.sessionId(), capturedNow);
if (setupProof != null) {
result.put("passwordSetupToken", setupProof);
result.put("passwordSetupExpiresIn", 600);
}
}
result.put("accessToken", tokens.accessToken());
result.put("refreshToken", tokens.refreshToken());
result.put("expiresIn", EXPIRES_IN_SECONDS);
@ -141,6 +150,7 @@ public class AuthService {
result.put("role", user.getRole());
result.put("identitySelectionRequired", user.getRole() == User.Role.UNASSIGNED);
result.put("active", user.getActive());
result.put("passwordConfigured", user.getPasswordHash() != null);
result.put("verified", user.getVerified());
result.put("createdAt", user.getCreatedAt());
return result;

2
mydreamjob-server/src/main/java/com/mydreamjob/auth/AuthSessionService.java

@ -336,7 +336,7 @@ public class AuthSessionService {
return authTokenHasher.hash("sid:" + sessionId).substring(0, 16);
}
static LocalDateTime toLocal(Instant instant) {
public static LocalDateTime toLocal(Instant instant) {
return LocalDateTime.ofInstant(instant, BUSINESS_ZONE);
}
}

5
mydreamjob-server/src/main/java/com/mydreamjob/auth/AuthTokenRepository.java

@ -53,6 +53,11 @@ public interface AuthTokenRepository extends JpaRepository<AuthToken, Long> {
List<AuthToken> findByUserIdAndSessionIdOrderByIdAsc(Long userId, String sessionId);
/** 首次密码设置持有当前会话行锁,让并发登出与设置按事务顺序生效。 */
@org.springframework.data.jpa.repository.Lock(jakarta.persistence.LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT t FROM AuthToken t WHERE t.userId = :userId AND t.sessionId = :sessionId ORDER BY t.id")
List<AuthToken> lockSession(@Param("userId") Long userId, @Param("sessionId") String sessionId);
// ── S02 原子条件更新(返回值必须被严格检查)────────────────────
/**

11
mydreamjob-server/src/main/java/com/mydreamjob/auth/dto/PasswordSetupRequest.java

@ -0,0 +1,11 @@
package com.mydreamjob.auth.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
public record PasswordSetupRequest(@NotBlank @Size(max = 512)
@io.swagger.v3.oas.annotations.media.Schema(accessMode = io.swagger.v3.oas.annotations.media.Schema.AccessMode.WRITE_ONLY) String passwordSetupToken,
@NotBlank @Size(max = 256)
@io.swagger.v3.oas.annotations.media.Schema(accessMode = io.swagger.v3.oas.annotations.media.Schema.AccessMode.WRITE_ONLY) String newPassword) {
@Override public String toString() { return "PasswordSetupRequest[redacted]"; }
}

6
mydreamjob-server/src/main/java/com/mydreamjob/auth/dto/PasswordSetupResponse.java

@ -0,0 +1,6 @@
package com.mydreamjob.auth.dto;
public record PasswordSetupResponse(boolean passwordConfigured, boolean reauthenticationRequired,
boolean identitySelectionRequired, String accessToken, String refreshToken, long expiresIn) {
@Override public String toString() { return "PasswordSetupResponse[redacted]"; }
}

2
mydreamjob-server/src/main/java/com/mydreamjob/auth/identity/IdentityAccessFilter.java

@ -24,7 +24,7 @@ import java.util.Set;
@RequiredArgsConstructor
public class IdentityAccessFilter extends OncePerRequestFilter {
private static final Set<String> AUTH_POST = Set.of("/auth/identity/select", "/auth/logout",
"/auth/password/set", "/auth/password/change", "/auth/login", "/auth/password/login",
"/auth/password/set", "/auth/password/setup", "/auth/password/change", "/auth/login", "/auth/password/login",
"/auth/password/reset", "/auth/refresh", "/auth/sms/send", "/auth/company/register");
private static final Set<String> PUBLIC_GET = Set.of("/", "/health", "/health/**", "/jobs",
"/jobs/search", "/jobs/filters", "/jobs/compare", "/jobs/public/**", "/jobs/*/reviews",

4
mydreamjob-server/src/main/java/com/mydreamjob/auth/password/PasswordAuthenticationService.java

@ -60,6 +60,7 @@ public class PasswordAuthenticationService {
result.put("nickname", user.getNickname());
result.put("role", user.getRole());
result.put("identitySelectionRequired", user.getRole() == User.Role.UNASSIGNED);
result.put("passwordConfigured", true);
result.put("accessToken", tokens.accessToken());
result.put("refreshToken", tokens.refreshToken());
result.put("expiresIn", AuthService.EXPIRES_IN_SECONDS);
@ -117,7 +118,8 @@ public class PasswordAuthenticationService {
return new PasswordOperationResult(true, true);
}
private User lockedUsableUser(Long userId) {
@Transactional(propagation = org.springframework.transaction.annotation.Propagation.MANDATORY)
public User lockedUsableUser(Long userId) {
User user = userRepository.findByIdForUpdate(userId)
.orElseThrow(() -> new BusinessException(ErrorCode.CREDENTIALS_INVALID, "手机号或密码错误"));
if (!usable(user)) {

18
mydreamjob-server/src/main/java/com/mydreamjob/auth/password/PasswordSetupChallenge.java

@ -0,0 +1,18 @@
package com.mydreamjob.auth.password;
import jakarta.persistence.*;
import java.time.LocalDateTime;
import lombok.*;
@Entity
@Table(name = "password_setup_challenges")
@Getter @Setter @Builder @NoArgsConstructor @AllArgsConstructor
public class PasswordSetupChallenge {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id;
@Column(nullable = false) private Long userId;
@Column(nullable = false, length = 36) private String sessionId;
@Column(nullable = false, unique = true, length = 64) private String tokenHash;
@Column(nullable = false) private LocalDateTime createdAt;
@Column(nullable = false) private LocalDateTime expiresAt;
private LocalDateTime consumedAt;
}

16
mydreamjob-server/src/main/java/com/mydreamjob/auth/password/PasswordSetupChallengeRepository.java

@ -0,0 +1,16 @@
package com.mydreamjob.auth.password;
import java.time.LocalDateTime;
import org.springframework.data.jpa.repository.*;
import org.springframework.data.repository.query.Param;
public interface PasswordSetupChallengeRepository extends JpaRepository<PasswordSetupChallenge, Long> {
@Modifying(flushAutomatically = true)
@Query("""
update PasswordSetupChallenge c set c.consumedAt = :now
where c.userId = :user and c.sessionId = :session and c.tokenHash = :hash
and c.consumedAt is null and c.expiresAt > :now
""")
int consume(@Param("user") Long userId, @Param("session") String sessionId,
@Param("hash") String hash, @Param("now") LocalDateTime now);
}

73
mydreamjob-server/src/main/java/com/mydreamjob/auth/password/PasswordSetupService.java

@ -0,0 +1,73 @@
package com.mydreamjob.auth.password;
import com.mydreamjob.auth.*;
import com.mydreamjob.auth.dto.PasswordSetupResponse;
import com.mydreamjob.audit.*;
import com.mydreamjob.common.exception.*;
import java.security.SecureRandom;
import java.time.*;
import java.util.Base64;
import lombok.RequiredArgsConstructor;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/** 短信登录后的首次密码设置;凭证只绑定当前会话,不用会话创建时间推断认证来源。 */
@Service @RequiredArgsConstructor
public class PasswordSetupService {
private static final SecureRandom RANDOM = new SecureRandom();
private final PasswordSetupChallengeRepository challenges;
private final AuthTokenHasher hasher;
private final AuthTokenRepository tokens;
private final PasswordAuthenticationService passwords;
private final PasswordRateLimiter limiter;
private final PasswordPolicy policy;
private final PasswordEncoder encoder;
private final UserRepository users;
private final AuthSessionService sessions;
private final AuditService audit;
private final Clock clock;
private final PasswordAuthenticationProperties properties;
@Transactional
public String issue(User user, String sessionId, Instant now) {
if (!properties.isEnabled() || user.getPasswordHash() != null) return null;
byte[] random = new byte[32];
RANDOM.nextBytes(random);
String raw = Base64.getUrlEncoder().withoutPadding().encodeToString(random);
challenges.save(PasswordSetupChallenge.builder().userId(user.getId()).sessionId(sessionId)
.tokenHash(hash(raw)).createdAt(AuthSessionService.toLocal(now))
.expiresAt(AuthSessionService.toLocal(now.plusSeconds(600))).build());
return raw;
}
@Transactional
public PasswordSetupResponse setup(Long userId, String sessionId, String raw, String newPassword, AuditContext ctx) {
User user = passwords.lockedUsableUser(userId);
Instant now = clock.instant();
boolean active = tokens.lockSession(userId, sessionId).stream().anyMatch(t ->
Boolean.FALSE.equals(t.getRevoked()) && t.getConsumedAt() == null
&& t.getExpiredAt().isAfter(AuthSessionService.toLocal(now)));
if (!active)
throw new InvalidTokenException();
limiter.checkOperationAllowed(user.getPhone());
if (user.getPasswordHash() != null) throw new BusinessException(ErrorCode.PASSWORD_STATE_CONFLICT);
policy.validateNewPassword(newPassword, user.getPhone(), null, encoder);
if (raw == null || raw.length() != 43 || challenges.consume(userId, sessionId, hash(raw),
AuthSessionService.toLocal(now)) != 1)
throw new BusinessException(ErrorCode.CREDENTIALS_INVALID, "invalid password setup proof");
user.setPasswordHash(encoder.encode(policy.normalize(newPassword)));
user.setPasswordChangedAt(AuthSessionService.toLocal(now));
users.saveAndFlush(user);
sessions.revokeAllUserSessions(userId, "PASSWORD_SET", now);
var pair = sessions.createSession(user, now, ctx);
audit.log(userId, "PASSWORD_SET", "AUTH", null, "First password configured", ctx,
AuthSessionService.toLocal(now));
audit.log(userId, "AUTH_SESSIONS_REVOKED", "AUTH", null, "PASSWORD_SET", ctx,
AuthSessionService.toLocal(now));
return new PasswordSetupResponse(true, false, user.getRole() == User.Role.UNASSIGNED,
pair.accessToken(), pair.refreshToken(), AuthService.EXPIRES_IN_SECONDS);
}
private String hash(String raw) { return hasher.hash("password-setup:" + raw); }
}

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

@ -100,6 +100,7 @@ public class SecurityConfig {
// S02:登出必须已认证
.requestMatchers("/auth/logout").authenticated()
.requestMatchers("/auth/password/set").authenticated()
.requestMatchers("/auth/password/setup").authenticated()
.requestMatchers("/auth/password/change").authenticated()
// 企业资料(需登录)
.requestMatchers("/companies/me/**").authenticated()

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

@ -59,6 +59,29 @@ public class AuthController {
private final PasswordAuthenticationService passwordAuthenticationService;
private final CompanyRegistrationService companyRegistrationService;
private final IdentitySelectionService identitySelectionService;
private final com.mydreamjob.auth.password.PasswordSetupService passwordSetupService;
@Operation(summary = "短信登录后首次设置密码", description = "需要当前 Access Token 和 10 分钟一次性设置凭证;成功后必须替换旧 Token")
@PostMapping("/password/setup")
public ResponseEntity<ApiResponse<com.mydreamjob.auth.dto.PasswordSetupResponse>> setupPassword(
@Valid @RequestBody com.mydreamjob.auth.dto.PasswordSetupRequest body, HttpServletRequest request) {
try {
var result = passwordSetupService.setup(currentUserAccessor.requireUserId(),
currentUserAccessor.requireSessionId(), body.passwordSetupToken(), body.newPassword(), AuditContext.from(request));
return ResponseEntity.ok(ApiResponse.ok(result, requestIdAccessor.get(request)));
} catch (InvalidTokenException e) { return unauthorized(request); }
catch (BusinessException e) {
if (e.getErrorCodeInt() == ErrorCode.SERVICE_UNAVAILABLE.getCode()) {
return ResponseEntity.status(503).body(ApiResponse.error(503,
"authentication service temporarily unavailable", requestIdAccessor.get(request)));
}
throw e;
}
catch (org.springframework.dao.DataAccessException | org.springframework.transaction.TransactionException e) {
return ResponseEntity.status(503).body(ApiResponse.error(503,
"authentication service temporarily unavailable", requestIdAccessor.get(request)));
}
}
@Operation(summary = "首次选择账号身份", description = "统一账号一次性选择求职者或企业,成功后替换旧登录凭证")
@PostMapping("/identity/select")
@ -120,8 +143,8 @@ public class AuthController {
return ResponseEntity.badRequest().body(
ApiResponse.error(400, e.getMessage(), requestIdAccessor.get(request)));
} catch (Exception e) {
return ResponseEntity.badRequest().body(
ApiResponse.error(400, e.getMessage(), requestIdAccessor.get(request)));
return ResponseEntity.status(503).body(
ApiResponse.error(503, "authentication service temporarily unavailable", requestIdAccessor.get(request)));
}
}

11
mydreamjob-server/src/main/resources/db/migration/V54__password_setup_challenges.sql

@ -0,0 +1,11 @@
CREATE TABLE password_setup_challenges (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
user_id BIGINT NOT NULL,
session_id VARCHAR(36) NOT NULL,
token_hash VARCHAR(64) NOT NULL,
created_at DATETIME(6) NOT NULL,
expires_at DATETIME(6) NOT NULL,
consumed_at DATETIME(6) NULL,
CONSTRAINT uk_password_setup_hash UNIQUE (token_hash),
CONSTRAINT fk_password_setup_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);

4
mydreamjob-server/src/test/java/com/mydreamjob/application/CompanyResumeDownloadIntegrationTest.java

@ -462,8 +462,8 @@ class CompanyResumeDownloadIntegrationTest {
}
assertThat(modulePaths).as("N03/N04/N05 新模块路径").isEqualTo(13);
assertThat(moduleOperations).as("N03/N04/N05 新模块操作").isEqualTo(18);
assertThat(pathCount - modulePaths).as("既有 184 paths 保持不变").isEqualTo(184);
assertThat(opCount - moduleOperations).as("既有 222 operations 保持不变").isEqualTo(222);
assertThat(pathCount - modulePaths).as("185 paths 包含首次密码设置").isEqualTo(185);
assertThat(opCount - moduleOperations).as("223 operations 包含首次密码设置").isEqualTo(223);
var download = paths.path("/company/candidates/{applicationId}/resume").path("get");
assertThat(download.isMissingNode()).as("必须存在企业简历下载 GET").isFalse();

172
mydreamjob-server/src/test/java/com/mydreamjob/auth/AuthPasswordSetupIntegrationTest.java

@ -0,0 +1,172 @@
package com.mydreamjob.auth;
import com.fasterxml.jackson.databind.*;
import com.mydreamjob.audit.*;
import com.mydreamjob.auth.password.*;
import java.time.*;
import java.util.*;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicInteger;
import org.junit.jupiter.api.*;
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.*;
import org.springframework.http.MediaType;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.test.context.*;
import org.springframework.test.web.servlet.MockMvc;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
@SpringBootTest @AutoConfigureMockMvc(print = org.springframework.boot.test.autoconfigure.web.servlet.MockMvcPrint.NONE) @ActiveProfiles("integration-test")
@TestPropertySource(properties = {"app.auth.password.enabled=true", "app.auth.password.rate-key-secret=01234567890123456789012345678901"})
class AuthPasswordSetupIntegrationTest {
@Autowired MockMvc mvc;
@Autowired ObjectMapper mapper;
@Autowired UserRepository users;
@Autowired AuthTokenRepository tokens;
@Autowired SmsVerificationCodeRepository codes;
@Autowired PasswordSetupChallengeRepository challenges;
@Autowired PasswordEncoder encoder;
@SpyBean AuthSessionService sessions;
@SpyBean AuditService audit;
@MockBean PasswordRateLimiter limiter;
@MockBean org.springframework.data.redis.core.RedisTemplate<String, Object> redis;
static final AtomicInteger SEQ = new AtomicInteger();
final List<Long> ids = new ArrayList<>();
final List<Long> codeIds = new ArrayList<>();
static final String PASSWORD = "Fixture-Strong-2026!";
@AfterEach void cleanup() {
reset(audit);
reset(sessions);
challenges.deleteAll(challenges.findAll().stream().filter(c -> ids.contains(c.getUserId())).toList());
tokens.deleteAll(tokens.findAll().stream().filter(t -> ids.contains(t.getUserId())).toList());
codeIds.forEach(codes::deleteById);
ids.forEach(users::deleteById);
}
User user() {
User u = users.saveAndFlush(User.builder().phone("1990077" + String.format("%04d",SEQ.incrementAndGet()))
.role(User.Role.UNASSIGNED).roles(Set.of()).active(true).verified(true).build());
ids.add(u.getId()); return u;
}
JsonNode login(User u) throws Exception {
codeIds.add(codes.saveAndFlush(SmsVerificationCode.builder().phone(u.getPhone()).code("654321")
.purpose("login").expiredAt(LocalDateTime.now().plusMinutes(5)).build()).getId());
var r=mvc.perform(post("/auth/login").contentType(MediaType.APPLICATION_JSON)
.content(mapper.writeValueAsString(Map.of("phone",u.getPhone(),"code","654321")))).andReturn().getResponse();
assertThat(r.getStatus()).isEqualTo(200);
return mapper.readTree(r.getContentAsString()).path("data");
}
org.springframework.mock.web.MockHttpServletResponse setup(JsonNode login, String proof) throws Exception {
return mvc.perform(post("/auth/password/setup").header("Authorization","Bearer "+login.path("accessToken").asText())
.contentType(MediaType.APPLICATION_JSON).content(mapper.writeValueAsString(Map.of(
"passwordSetupToken",proof,"newPassword",PASSWORD)))).andReturn().getResponse();
}
@Test void setsPasswordRotatesSessionsAndKeepsSmsLogin() throws Exception {
User u=user(); JsonNode l=login(u);
assertThat(l.path("passwordConfigured").asBoolean()).isFalse();
String proof=l.path("passwordSetupToken").asText();
assertThat(proof).hasSize(43);
assertThat(challenges.findAll().stream().filter(c->c.getUserId().equals(u.getId())).findFirst().orElseThrow().getTokenHash()).isNotEqualTo(proof);
var r=setup(l,proof); assertThat(r.getStatus()).isEqualTo(200);
JsonNode n=mapper.readTree(r.getContentAsString()).path("data");
assertThat(n.path("passwordConfigured").asBoolean()).isTrue();
assertThat(n.path("reauthenticationRequired").asBoolean()).isFalse();
assertThat(n.path("identitySelectionRequired").asBoolean()).isTrue();
assertThat(encoder.matches(PASSWORD,users.findById(u.getId()).orElseThrow().getPasswordHash())).isTrue();
assertThat(mvc.perform(get("/auth/me").header("Authorization","Bearer "+l.path("accessToken").asText())).andReturn().getResponse().getStatus()).isEqualTo(401);
assertThat(mvc.perform(get("/auth/me").header("Authorization","Bearer "+n.path("accessToken").asText())).andReturn().getResponse().getStatus()).isEqualTo(200);
assertThat(mvc.perform(post("/auth/refresh").contentType(MediaType.APPLICATION_JSON).content(mapper.writeValueAsString(Map.of("refreshToken",l.path("refreshToken").asText())))).andReturn().getResponse().getStatus()).isEqualTo(401);
assertThat(setup(n,proof).getStatus()).isEqualTo(409);
JsonNode again=login(users.findById(u.getId()).orElseThrow());
assertThat(again.path("passwordConfigured").asBoolean()).isTrue();
assertThat(again.has("passwordSetupToken")).isFalse();
assertThat(mvc.perform(post("/auth/password/login").contentType(MediaType.APPLICATION_JSON).content(mapper.writeValueAsString(Map.of("phone",u.getPhone(),"password",PASSWORD)))).andReturn().getResponse().getStatus()).isEqualTo(200);
}
@Test void bindsProofToUserAndSessionAndExpires() throws Exception {
User u=user(); JsonNode a=login(u), b=login(u), other=login(user());
String proof=a.path("passwordSetupToken").asText();
assertThat(setup(b,proof).getStatus()).isEqualTo(401);
assertThat(setup(other,proof).getStatus()).isEqualTo(401);
challenges.findAll().stream().filter(c->c.getUserId().equals(u.getId())).forEach(c->{c.setExpiresAt(LocalDateTime.now().minusMinutes(1));challenges.saveAndFlush(c);});
assertThat(setup(a,proof).getStatus()).isEqualTo(401);
assertThat(users.findById(u.getId()).orElseThrow().getPasswordHash()).isNull();
}
@Test void ordinarySessionHasNoSetupAuthorityAndAnonymousIsRejected() throws Exception {
User u=user(); var pair=sessions.createSession(u,Instant.now(),new AuditContext());
JsonNode plain=mapper.valueToTree(Map.of("accessToken",pair.accessToken()));
assertThat(setup(plain,"A".repeat(43)).getStatus()).isEqualTo(401);
assertThat(mvc.perform(post("/auth/password/setup").contentType(MediaType.APPLICATION_JSON).content("{}"))
.andReturn().getResponse().getStatus()).isEqualTo(401);
}
@Test void concurrentRequestsOnlyOneSucceeds() throws Exception {
User u=user(); JsonNode l=login(u); String proof=l.path("passwordSetupToken").asText();
var pool=Executors.newFixedThreadPool(2); var start=new CountDownLatch(1);
try {
Callable<Integer> task=()->{start.await();return setup(l,proof).getStatus();};
var a=pool.submit(task);var b=pool.submit(task);start.countDown();
var results=List.of(a.get(30,TimeUnit.SECONDS),b.get(30,TimeUnit.SECONDS));
assertThat(results).contains(200).containsAnyOf(401,409);
assertThat(results.stream().filter(s->s==200).count()).isEqualTo(1);
assertThat(tokens.findByUserIdAndRevokedFalse(u.getId())).hasSize(1);
} finally {pool.shutdownNow();}
}
@Test void auditFailureRollsBackProofPasswordAndRevocation() throws Exception {
User u=user();JsonNode l=login(u);String proof=l.path("passwordSetupToken").asText();
doThrow(new IllegalStateException("fixture audit failure")).when(audit).log(eq(u.getId()),eq("PASSWORD_SET"),eq("AUTH"),isNull(),anyString(),any(AuditContext.class),any(LocalDateTime.class));
var r=setup(l,proof);assertThat(r.getStatus()).isGreaterThanOrEqualTo(500);
assertThat(users.findById(u.getId()).orElseThrow().getPasswordHash()).isNull();
assertThat(challenges.findAll().stream().filter(c->c.getUserId().equals(u.getId())).findFirst().orElseThrow().getConsumedAt()).isNull();
assertThat(tokens.findByUserIdAndRevokedFalse(u.getId())).hasSize(1);
reset(audit);assertThat(setup(l,proof).getStatus()).isEqualTo(200);
}
@Test void refreshDoesNotIssueOrExtendProof() throws Exception {
User u=user(); JsonNode l=login(u);
var before=challenges.findAll().stream().filter(c->c.getUserId().equals(u.getId())).findFirst().orElseThrow();
var r=mvc.perform(post("/auth/refresh").contentType(MediaType.APPLICATION_JSON).content(mapper.writeValueAsString(Map.of("refreshToken",l.path("refreshToken").asText())))).andReturn().getResponse();
assertThat(r.getStatus()).isEqualTo(200);
JsonNode refreshed=mapper.readTree(r.getContentAsString()).path("data");
assertThat(refreshed.has("passwordSetupToken")).isFalse();
assertThat(challenges.findById(before.getId()).orElseThrow().getExpiresAt()).isEqualTo(before.getExpiresAt());
assertThat(setup(refreshed,l.path("passwordSetupToken").asText()).getStatus()).isEqualTo(200);
}
@Test void sessionCreationFailureRollsBackAllChanges() throws Exception {
User u=user();JsonNode l=login(u);String proof=l.path("passwordSetupToken").asText();
doThrow(new org.springframework.dao.DataAccessResourceFailureException("fixture session failure")).when(sessions).createSession(any(User.class),any(Instant.class),any(AuditContext.class));
assertThat(setup(l,proof).getStatus()).isEqualTo(503);
assertThat(users.findById(u.getId()).orElseThrow().getPasswordHash()).isNull();
assertThat(tokens.findByUserIdAndRevokedFalse(u.getId())).hasSize(1);
assertThat(challenges.findAll().stream().filter(c->c.getUserId().equals(u.getId())).findFirst().orElseThrow().getConsumedAt()).isNull();
reset(sessions);assertThat(setup(l,proof).getStatus()).isEqualTo(200);
}
@Test void weakPasswordOrUnavailableLimiterDoesNotConsumeProof() throws Exception {
User u=user();JsonNode l=login(u);String proof=l.path("passwordSetupToken").asText();
var r=mvc.perform(post("/auth/password/setup").header("Authorization","Bearer "+l.path("accessToken").asText())
.contentType(MediaType.APPLICATION_JSON).content(mapper.writeValueAsString(Map.of("passwordSetupToken",proof,"newPassword","123")))).andReturn().getResponse();
assertThat(r.getStatus()).isEqualTo(400);
doThrow(new com.mydreamjob.common.exception.BusinessException(com.mydreamjob.common.exception.ErrorCode.SERVICE_UNAVAILABLE)).when(limiter).checkOperationAllowed(u.getPhone());
assertThat(setup(l,proof).getStatus()).isEqualTo(503);
assertThat(users.findById(u.getId()).orElseThrow().getPasswordHash()).isNull();
assertThat(challenges.findAll().stream().filter(c->c.getUserId().equals(u.getId())).findFirst().orElseThrow().getConsumedAt()).isNull();
reset(limiter);assertThat(setup(l,proof).getStatus()).isEqualTo(200);
}
@Test void loggedOutSessionCannotUseUnconsumedProof() throws Exception {
User u=user();JsonNode l=login(u);
assertThat(mvc.perform(post("/auth/logout").header("Authorization","Bearer "+l.path("accessToken").asText())).andReturn().getResponse().getStatus()).isEqualTo(200);
assertThat(setup(l,l.path("passwordSetupToken").asText()).getStatus()).isEqualTo(401);
assertThat(users.findById(u.getId()).orElseThrow().getPasswordHash()).isNull();
}
@Test void configuredIdentityIsPreserved() throws Exception {
User u=user();u.setRole(User.Role.STUDENT);u.setRoles(Set.of(User.Role.STUDENT));users.saveAndFlush(u);
JsonNode l=login(u);var r=setup(l,l.path("passwordSetupToken").asText());
assertThat(r.getStatus()).isEqualTo(200);
JsonNode n=mapper.readTree(r.getContentAsString()).path("data");
assertThat(n.path("identitySelectionRequired").asBoolean()).isFalse();
var me=mvc.perform(get("/auth/me").header("Authorization","Bearer "+n.path("accessToken").asText())).andReturn().getResponse();
assertThat(mapper.readTree(me.getContentAsString()).path("data").path("role").asText()).isEqualTo("STUDENT");
}
}

20
mydreamjob-server/src/test/java/com/mydreamjob/auth/AuthPasswordSetupMySqlTest.java

@ -0,0 +1,20 @@
package com.mydreamjob.auth;
import org.springframework.test.context.*;
import org.springframework.test.annotation.DirtiesContext;
import org.testcontainers.containers.MySQLContainer;
import org.testcontainers.junit.jupiter.*;
/** 与 H2 使用相同验收断言,在真实 MySQL 上验证迁移、并发和事务回滚。 */
@Testcontainers(disabledWithoutDocker = false)
@ActiveProfiles(value = "mysql-it", inheritProfiles = false)
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS)
class AuthPasswordSetupMySqlTest extends AuthPasswordSetupIntegrationTest {
@Container static final MySQLContainer<?> MYSQL = new MySQLContainer<>("mysql:8.4")
.withDatabaseName("password_setup_test").withUsername("setup_test").withPassword("setup-fixture-only");
@DynamicPropertySource static void database(DynamicPropertyRegistry r) {
r.add("spring.datasource.url",MYSQL::getJdbcUrl);
r.add("spring.datasource.username",MYSQL::getUsername);
r.add("spring.datasource.password",MYSQL::getPassword);
}
}

2
mydreamjob-server/src/test/java/com/mydreamjob/auth/AuthServiceTest.java

@ -57,6 +57,8 @@ class AuthServiceTest {
@Mock
private Clock clock;
@Mock
private com.mydreamjob.auth.password.PasswordSetupService passwordSetupService;
@InjectMocks
private AuthService authService;

8
mydreamjob-server/src/test/java/com/mydreamjob/auth/identity/UnifiedIdentityJarAcceptanceProbe.java

@ -52,8 +52,8 @@ public final class UnifiedIdentityJarAcceptanceProbe {
JsonNode runtime=call("GET","/v3/api-docs",null,null,200);
JsonNode contract=JSON.readTree(Files.readString(Path.of("../docs/openapi-rc.json")));
assertThat(operations(runtime)).isEqualTo(operations(contract)); checks++;
assertThat(runtime.path("paths").size()).isEqualTo(197); checks++;
assertThat(operations(runtime)).hasSize(240); checks++;
assertThat(runtime.path("paths").size()).isEqualTo(198); checks++;
assertThat(operations(runtime)).hasSize(241); checks++;
JsonNode pending=login("19900009602");
assertThat(pending.path("role").asText()).isEqualTo("UNASSIGNED"); checks++;
assertThat(pending.path("identitySelectionRequired").asBoolean()).isTrue(); checks++;
@ -125,9 +125,9 @@ public final class UnifiedIdentityJarAcceptanceProbe {
}
Files.writeString(Path.of("target/identity-probe-result.json"),JSON.writeValueAsString(Map.of(
"checks",checks,"failures",0,"mysql","8.4","redis","7.4","rabbitmq","3",
"paths",197,"operations",240,"smsDelivery","mock sender; not real provider",
"paths",198,"operations",241,"smsDelivery","mock sender; not real provider",
"fixtureResidual",0,"auditPolicy","append-only; isolated container lifecycle")));
System.out.println("Unified identity latest-JAR acceptance PASS: "+checks+" checks; 197 paths/240 operations; fixture residual 0");
System.out.println("Unified identity latest-JAR acceptance PASS: "+checks+" checks; 198 paths/241 operations; fixture residual 0");
} finally {stop();}
}
}

2
mydreamjob-server/src/test/java/com/mydreamjob/auth/identity/UnifiedRegistrationIntegrationTest.java

@ -187,7 +187,7 @@ class UnifiedRegistrationIntegrationTest {
"/jobs/compare","/jobs/public/{jobId}","/jobs/{jobId}/reviews","/jobs/{jobId}/reviews/{reviewId}",
"/jobs/{jobId}/reviews/summary","/companies/{companyId}","/companies/{companyId}/jobs",
"/job-recommendation-taxonomies","/auth/me");
Set<String> accountPost=Set.of("/auth/identity/select","/auth/logout","/auth/password/set",
Set<String> accountPost=Set.of("/auth/identity/select","/auth/logout","/auth/password/set","/auth/password/setup",
"/auth/password/change","/auth/login","/auth/password/login","/auth/password/reset",
"/auth/refresh","/auth/sms/send","/auth/company/register");
int denied=0;

2
mydreamjob-server/src/test/java/com/mydreamjob/config/MySql57MigrationConfigurationTest.java

@ -27,7 +27,7 @@ class MySql57MigrationConfigurationTest {
var provider = config.getResourceProvider();
assertThat(provider).isNotNull();
var resources = provider.getResources("V", new String[]{".sql"});
assertThat(resources).hasSize(53);
assertThat(resources).hasSize(54);
for (var resource : resources) {
String path = "V49__skill_cloud_boolean_type.sql".equals(resource.getFilename())
? "db/mysql57/" : "db/migration/";

4
mydreamjob-server/src/test/java/com/mydreamjob/integration/LxqModulesMySql57IntegrationTest.java

@ -58,8 +58,8 @@ class LxqModulesMySql57IntegrationTest {
JsonNode runtime = mapper.readTree(mvc.perform(get("/v3/api-docs"))
.andExpect(status().isOk()).andReturn().getResponse().getContentAsByteArray());
JsonNode contract = mapper.readTree(Files.readString(Path.of("../docs/openapi-rc.json")));
assertThat(operations(runtime)).hasSize(240).isEqualTo(operations(contract));
assertThat(runtime.path("paths").size()).isEqualTo(197);
assertThat(operations(runtime)).hasSize(241).isEqualTo(operations(contract));
assertThat(runtime.path("paths").size()).isEqualTo(198);
}
private Set<String> operations(JsonNode document) {

4
mydreamjob-server/src/test/java/com/mydreamjob/integration/LxqModulesMySqlIntegrationTest.java

@ -59,8 +59,8 @@ class LxqModulesMySqlIntegrationTest {
JsonNode runtime = mapper.readTree(mvc.perform(get("/v3/api-docs"))
.andExpect(status().isOk()).andReturn().getResponse().getContentAsByteArray());
JsonNode contract = mapper.readTree(Files.readString(Path.of("../docs/openapi-rc.json")));
assertThat(operations(runtime)).hasSize(240).isEqualTo(operations(contract));
assertThat(runtime.path("paths").size()).isEqualTo(197);
assertThat(operations(runtime)).hasSize(241).isEqualTo(operations(contract));
assertThat(runtime.path("paths").size()).isEqualTo(198);
}
private Set<String> operations(JsonNode document) {

Loading…
Cancel
Save