feat: 宠物 CRUD 与 pet_owners 三角色权限框架(T2-03)
CI / backend-test (push) Successful in 7m22s

- GET/POST /api/v1/pets、GET/PATCH /api/v1/pets/{petId}、GET /api/v1/breeds
  (species 过滤);创建者自动写入 pet_owners primary owner(同事务)
- PetAccessService 统一权限闸口(READ/WRITE/MANAGE 三档 × owner/caregiver/
  viewer 三角色,ADR-015):无关系一律 404/40401 防枚举,可见越权 403/40300;
  每请求实时查库无缓存,撤销关系即时生效;T2-04~07 复用同一入口
- PATCH version 乐观锁(40902);部分更新语义;status 白名单排除 deleted,
  软删除不可经 PATCH 绕过 ck_pets_deleted
- ck_pets_breed 互斥 + 字典品种存在/物种匹配应用层先行校验(40000);
  芯片号唯一冲突新错误码 40903 MICROCHIP_EXISTS
- pet 模块接入 RS256 bearer 鉴权(与 user 同一公钥约定),compose 挂载公钥
- 测试基建:test classpath 引入 patbond-user + Flyway,Testcontainers 上跑
  完整 V1..V4 迁移链;三角色以测试数据直写 pet_owners 构造(T2-10)
- 集成测试覆盖六类路径 23 例;全套 118 测试全绿(基线 95)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-07 17:27:44 +08:00
parent 58576f8ebc
commit 8fbf44472d
28 changed files with 1893 additions and 14 deletions
+5 -3
View File
@@ -60,9 +60,9 @@ services:
depends_on: depends_on:
- user - user
# M2(ADR-009):宠物健康档案服务。第一波为骨架(仅 /health 探活,无业务端点)。 # M2(ADR-009):宠物健康档案服务。第二波起提供 /api/v1/pets、/api/v1/breeds
# 与 user 共库;Flyway 迁移链由 user 服务统一执行,故依赖 user 先起, # 业务端点(RS256 校验,与 user 同一公钥)。与 user 共库;Flyway 迁移链由
# 保证 pet_health schema 已就绪。 # user 服务统一执行,故依赖 user 先起,保证 pet_health schema 已就绪。
pet: pet:
build: ./patbond-pet build: ./patbond-pet
environment: environment:
@@ -70,8 +70,10 @@ services:
PATBOND_DB_URL: jdbc:postgresql://postgres:5432/${PATBOND_DB_NAME:-patbond} PATBOND_DB_URL: jdbc:postgresql://postgres:5432/${PATBOND_DB_NAME:-patbond}
PATBOND_DB_USER: ${PATBOND_DB_USER:-patbond} PATBOND_DB_USER: ${PATBOND_DB_USER:-patbond}
PATBOND_DB_PASSWORD: ${PATBOND_DB_PASSWORD:?先运行 deploy/init-secrets.sh 生成 .env} PATBOND_DB_PASSWORD: ${PATBOND_DB_PASSWORD:?先运行 deploy/init-secrets.sh 生成 .env}
PATBOND_JWT_PUBLIC_KEY: /run/patbond/keys/jwt-public.pem
volumes: volumes:
- ./patbond-pet/src/main/resources/application.yml.sample:/config/application.yml:ro - ./patbond-pet/src/main/resources/application.yml.sample:/config/application.yml:ro
- ./deploy/keys:/run/patbond/keys:ro
ports: ports:
- "${PATBOND_PET_PORT:-8083}:8083" - "${PATBOND_PET_PORT:-8083}:8083"
depends_on: depends_on:
@@ -20,6 +20,7 @@ public enum ErrorCode {
USERNAME_EXISTS(40900, 409, "用户名已存在"), USERNAME_EXISTS(40900, 409, "用户名已存在"),
PHONE_EXISTS(40901, 409, "手机号已被使用"), PHONE_EXISTS(40901, 409, "手机号已被使用"),
VERSION_CONFLICT(40902, 409, "数据已被修改,请刷新后重试"), VERSION_CONFLICT(40902, 409, "数据已被修改,请刷新后重试"),
MICROCHIP_EXISTS(40903, 409, "芯片号已被其他宠物登记"),
LOGIN_LOCKED(42300, 423, "登录失败次数过多,账号已临时锁定"), LOGIN_LOCKED(42300, 423, "登录失败次数过多,账号已临时锁定"),
INTERNAL_ERROR(50000, 500, "服务器内部错误"), INTERNAL_ERROR(50000, 500, "服务器内部错误"),
DOWNSTREAM_UNAVAILABLE(50300, 503, "依赖服务暂不可用"); DOWNSTREAM_UNAVAILABLE(50300, 503, "依赖服务暂不可用");
+44 -5
View File
@@ -17,11 +17,9 @@
<name>patbond-pet</name> <name>patbond-pet</name>
<description>Pet profile and health record service for Patbond (ADR-009)</description> <description>Pet profile and health record service for Patbond (ADR-009)</description>
<!-- M2 first-wave skeleton: web + datasource wiring and a liveness endpoint. <!-- M2 second-wave: business endpoints with RS256 bearer auth (same JWT
Flyway is intentionally absent — the single migration chain (V1..V4, verification stack as patbond-user), pet_health schema CRUD. Flyway
including the pet_health schema) is owned and applied by patbond-user, remains absent — the migration chain is owned by patbond-user. -->
the shared database's startup path. This module only reads/writes the
pet_health schema once business endpoints land (M2 second wave). -->
<dependencies> <dependencies>
<dependency> <dependency>
<groupId>com.patbond.patbond</groupId> <groupId>com.patbond.patbond</groupId>
@@ -45,11 +43,52 @@
<artifactId>postgresql</artifactId> <artifactId>postgresql</artifactId>
<scope>runtime</scope> <scope>runtime</scope>
</dependency> </dependency>
<!-- Access token verification (RS256, public key only): jjwt is not in
the Boot BOM, version pinned in step with patbond-user/auth. -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.12.6</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.12.6</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.12.6</version>
<scope>runtime</scope>
</dependency>
<dependency> <dependency>
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId> <artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope> <scope>test</scope>
</dependency> </dependency>
<!-- Tests need the pet_health schema. The migration chain (V1..V4) is
owned by patbond-user (single flyway_schema_history); pulling its
plain jar plus Flyway into the TEST classpath lets Boot's Flyway
auto-config apply the same chain to the disposable container.
Production wiring is unchanged: this module still ships without
Flyway and the chain runs in patbond-user's startup path. -->
<dependency>
<groupId>com.patbond.patbond</groupId>
<artifactId>patbond-user</artifactId>
<version>${project.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-core</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-database-postgresql</artifactId>
<scope>test</scope>
</dependency>
<dependency> <dependency>
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-testcontainers</artifactId> <artifactId>spring-boot-testcontainers</artifactId>
@@ -0,0 +1,29 @@
package com.patbond.patbond.pet.access;
/**
* What a request wants to do with a pet. Every /pets/** endpoint maps to
* exactly one level, checked by {@link PetAccessService}:
*
* <ul>
* <li>{@code READ} — owner, caregiver and viewer.</li>
* <li>{@code WRITE} — owner and caregiver; the level for health-record
* style sub-resources (weights, vaccinations, health events,
* reminders — T2-04~07 reuse this).</li>
* <li>{@code MANAGE} — owner only; the pet profile itself (PATCH /pets/
* {petId}, status transitions) and, later, member management.</li>
* </ul>
*/
public enum AccessLevel {
READ,
WRITE,
MANAGE;
boolean allowedFor(PetRole role) {
return switch (this) {
case READ -> true;
case WRITE -> role == PetRole.OWNER || role == PetRole.CAREGIVER;
case MANAGE -> role == PetRole.OWNER;
};
}
}
@@ -0,0 +1,69 @@
package com.patbond.patbond.pet.access;
import com.patbond.patbond.common.error.BusinessException;
import com.patbond.patbond.common.error.ErrorCode;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.stereotype.Service;
import java.util.UUID;
/**
* The single permission gate for every /pets/{petId}/** request — T2-04~07
* call {@link #require} first and then trust the returned {@link PetAccess}.
*
* <p>Semantics (frozen for the M2 contract, iteration-2/02 P7):
* <ul>
* <li>Pet does not exist, is soft-deleted, or the caller has NO row in
* pet_owners → {@code 404/40401 PET_NOT_FOUND}. A caller without a
* relationship cannot distinguish "someone else's pet" from "no such
* pet" — anti-enumeration: the response must not leak that a random
* UUID hits a real record.</li>
* <li>Relationship exists but the role does not cover the requested
* {@link AccessLevel} (e.g. viewer tries to write) →
* {@code 403/40300 PET_ACCESS_DENIED}.</li>
* </ul>
*
* <p>The check is one indexed query (pets ⋈ pet_owners, both on their
* primary keys) per request — no caching, so revoking a caregiver row takes
* effect immediately (iteration-2/02 §6: this is why pet permissions don't
* need an access-token blacklist).
*/
@Service
public class PetAccessService {
private final JdbcClient jdbcClient;
public PetAccessService(JdbcClient jdbcClient) {
this.jdbcClient = jdbcClient;
}
/**
* Asserts the caller may act on the pet at the given level.
*
* @return the caller's resolved relationship, for handlers that need the
* role (e.g. pet detail echoes {@code myRole})
* @throws BusinessException 40401 (pet invisible to this caller) or
* 40300 (visible but insufficient role)
*/
public PetAccess require(UUID userId, UUID petId, AccessLevel level) {
PetAccess access = jdbcClient.sql("""
SELECT po.role
FROM pet_health.pets p
JOIN pet_health.pet_owners po ON po.pet_id = p.id AND po.user_id = :userId
WHERE p.id = :petId AND p.status <> 'deleted'
""")
.param("userId", userId)
.param("petId", petId)
.query((rs, rowNum) -> new PetAccess(petId, PetRole.fromDb(rs.getString("role"))))
.optional()
.orElseThrow(() -> new BusinessException(ErrorCode.PET_NOT_FOUND));
if (!level.allowedFor(access.role())) {
throw new BusinessException(ErrorCode.PET_ACCESS_DENIED);
}
return access;
}
/** The caller's verified relationship to a pet. */
public record PetAccess(UUID petId, PetRole role) {
}
}
@@ -0,0 +1,27 @@
package com.patbond.patbond.pet.access;
import com.patbond.patbond.common.error.BusinessException;
import com.patbond.patbond.common.error.ErrorCode;
/**
* The caller's relationship to a pet, straight from pet_owners.role
* (ADR-015 three-tier model: owner / caregiver / viewer).
*/
public enum PetRole {
OWNER,
CAREGIVER,
VIEWER;
public static PetRole fromDb(String value) {
try {
return valueOf(value.toUpperCase());
} catch (IllegalArgumentException | NullPointerException e) {
throw new BusinessException(ErrorCode.INTERNAL_ERROR, "未知的照护角色: " + value);
}
}
public String toWire() {
return name().toLowerCase();
}
}
@@ -0,0 +1,37 @@
package com.patbond.patbond.pet.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
/**
* Security knobs of the pet service: only the RS256 public key for verifying
* access tokens issued by patbond-auth (same contract as patbond-user's
* {@code patbond.jwt.public-key}). No /internal routes exist here yet, so no
* service token property.
*/
@ConfigurationProperties(prefix = "patbond")
public class PetSecurityProperties {
private final Jwt jwt = new Jwt();
public Jwt getJwt() {
return jwt;
}
public static class Jwt {
/**
* RS256 public key for verifying access tokens signed by
* patbond-auth: either inline PEM (starts with -----BEGIN) or a
* filesystem path. The private key never reaches this service.
*/
private String publicKey;
public String getPublicKey() {
return publicKey;
}
public void setPublicKey(String publicKey) {
this.publicKey = publicKey;
}
}
}
@@ -0,0 +1,34 @@
package com.patbond.patbond.pet.config;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.patbond.patbond.pet.security.BearerAuthFilter;
import com.patbond.patbond.pet.security.JwtVerifier;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* Wires bearer authentication for /api/v1/** without pulling in
* spring-security — the same single-filter pattern patbond-user uses. The
* /health probe stays outside /api/v1 and therefore unauthenticated.
*/
@Configuration
@EnableConfigurationProperties(PetSecurityProperties.class)
public class SecurityConfig {
@Bean
public JwtVerifier jwtVerifier(PetSecurityProperties properties) {
return new JwtVerifier(properties.getJwt().getPublicKey());
}
@Bean
public FilterRegistrationBean<BearerAuthFilter> bearerAuthFilter(
JwtVerifier jwtVerifier, ObjectMapper objectMapper) {
FilterRegistrationBean<BearerAuthFilter> registration = new FilterRegistrationBean<>(
new BearerAuthFilter(jwtVerifier, objectMapper));
registration.addUrlPatterns("/api/v1/*");
registration.setOrder(20);
return registration;
}
}
@@ -0,0 +1,38 @@
package com.patbond.patbond.pet.controller;
import com.patbond.patbond.common.response.ApiResponse;
import com.patbond.patbond.pet.dto.BreedResponse;
import com.patbond.patbond.pet.repository.BreedRepository;
import jakarta.validation.constraints.Pattern;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
/**
* Read-only breed dictionary. Authenticated (behind BearerAuthFilter like
* every /api/v1 route) but not permission-checked — dictionary rows are not
* user data. Returned in full (≈30 seed rows, D2-6); no pagination.
*/
@RestController
@RequestMapping("/api/v1/breeds")
@Validated
public class BreedController {
private final BreedRepository breedRepository;
public BreedController(BreedRepository breedRepository) {
this.breedRepository = breedRepository;
}
@GetMapping
public ApiResponse<List<BreedResponse>> list(
@RequestParam(required = false)
@Pattern(regexp = "dog|cat|other", message = "species 仅支持 dog/cat/other")
String species) {
return ApiResponse.success(breedRepository.listEnabled(species));
}
}
@@ -0,0 +1,68 @@
package com.patbond.patbond.pet.controller;
import com.patbond.patbond.common.response.ApiResponse;
import com.patbond.patbond.pet.dto.CreatePetRequest;
import com.patbond.patbond.pet.dto.PetResponse;
import com.patbond.patbond.pet.dto.UpdatePetRequest;
import com.patbond.patbond.pet.security.BearerAuthFilter;
import com.patbond.patbond.pet.service.PetService;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestAttribute;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
import java.util.UUID;
/**
* Pet profile CRUD (T2-03). Authentication is the filter's job; every
* pet-scoped handler delegates authorization to PetAccessService through
* PetService. The list endpoint needs no explicit check — its query is
* scoped to the caller's own pet_owners rows by construction.
*/
@RestController
@RequestMapping("/api/v1/pets")
public class PetController {
private final PetService petService;
public PetController(PetService petService) {
this.petService = petService;
}
@GetMapping
public ApiResponse<List<PetResponse>> list(
@RequestAttribute(BearerAuthFilter.USER_ID_ATTRIBUTE) UUID userId) {
return ApiResponse.success(petService.list(userId));
}
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public ApiResponse<PetResponse> create(
@RequestAttribute(BearerAuthFilter.USER_ID_ATTRIBUTE) UUID userId,
@Valid @RequestBody CreatePetRequest request) {
return ApiResponse.success(petService.create(userId, request));
}
@GetMapping("/{petId}")
public ApiResponse<PetResponse> get(
@RequestAttribute(BearerAuthFilter.USER_ID_ATTRIBUTE) UUID userId,
@PathVariable UUID petId) {
return ApiResponse.success(petService.get(userId, petId));
}
@PatchMapping("/{petId}")
public ApiResponse<PetResponse> update(
@RequestAttribute(BearerAuthFilter.USER_ID_ATTRIBUTE) UUID userId,
@PathVariable UUID petId,
@Valid @RequestBody UpdatePetRequest request) {
return ApiResponse.success(petService.update(userId, petId, request));
}
}
@@ -0,0 +1,11 @@
package com.patbond.patbond.pet.dto;
import java.util.UUID;
/** One row of the read-only breed dictionary (GET /api/v1/breeds). */
public record BreedResponse(
UUID id,
String species,
String code,
String displayName) {
}
@@ -0,0 +1,125 @@
package com.patbond.patbond.pet.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
import java.time.LocalDate;
import java.util.UUID;
/**
* POST /api/v1/pets. Exactly one of breedId / customBreedName must be given
* (mirrors ck_pets_breed); the cross-field rule is checked in the service so
* it can answer with a precise message. Species is fixed at creation — a
* later species change would invalidate the breed pairing.
*/
public class CreatePetRequest {
@NotBlank(message = "宠物名称不能为空")
@Size(max = 64, message = "宠物名称最长 64 字符")
private String name;
@NotBlank(message = "物种不能为空")
@Pattern(regexp = "dog|cat|other", message = "物种仅支持 dog/cat/other")
private String species;
private UUID breedId;
@Size(min = 1, max = 64, message = "自定义品种名称长度须在 1~64 字符")
private String customBreedName;
@Pattern(regexp = "male|female|unknown", message = "性别仅支持 male/female/unknown")
private String sex;
private LocalDate birthDate;
private Boolean birthDateEstimated;
@Size(max = 64, message = "性格描述最长 64 字符")
private String personality;
@Size(max = 64, message = "芯片号最长 64 字符")
private String microchipNo;
private LocalDate sterilizedOn;
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getSpecies() {
return species;
}
public void setSpecies(String species) {
this.species = species;
}
public UUID getBreedId() {
return breedId;
}
public void setBreedId(UUID breedId) {
this.breedId = breedId;
}
public String getCustomBreedName() {
return customBreedName;
}
public void setCustomBreedName(String customBreedName) {
this.customBreedName = customBreedName;
}
public String getSex() {
return sex;
}
public void setSex(String sex) {
this.sex = sex;
}
public LocalDate getBirthDate() {
return birthDate;
}
public void setBirthDate(LocalDate birthDate) {
this.birthDate = birthDate;
}
public Boolean getBirthDateEstimated() {
return birthDateEstimated;
}
public void setBirthDateEstimated(Boolean birthDateEstimated) {
this.birthDateEstimated = birthDateEstimated;
}
public String getPersonality() {
return personality;
}
public void setPersonality(String personality) {
this.personality = personality;
}
public String getMicrochipNo() {
return microchipNo;
}
public void setMicrochipNo(String microchipNo) {
this.microchipNo = microchipNo;
}
public LocalDate getSterilizedOn() {
return sterilizedOn;
}
public void setSterilizedOn(LocalDate sterilizedOn) {
this.sterilizedOn = sterilizedOn;
}
}
@@ -0,0 +1,31 @@
package com.patbond.patbond.pet.dto;
import java.time.LocalDate;
import java.time.OffsetDateTime;
import java.util.UUID;
/**
* A pet as the API returns it. {@code breedDisplayName} is resolved from the
* dictionary when {@code breedId} is set; exactly one of {@code breedId} /
* {@code customBreedName} is non-null (ck_pets_breed). {@code myRole} is the
* calling user's own pet_owners role — the client uses it to gate write UI.
*/
public record PetResponse(
UUID id,
String name,
String species,
UUID breedId,
String breedDisplayName,
String customBreedName,
String sex,
LocalDate birthDate,
Boolean birthDateEstimated,
String personality,
String microchipNo,
LocalDate sterilizedOn,
String status,
String myRole,
OffsetDateTime createdAt,
OffsetDateTime updatedAt,
Integer version) {
}
@@ -0,0 +1,146 @@
package com.patbond.patbond.pet.dto;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.PositiveOrZero;
import jakarta.validation.constraints.Size;
import java.time.LocalDate;
import java.util.UUID;
/**
* PATCH /api/v1/pets/{petId}. Partial update: an absent (or null) field is
* left unchanged — M2 does not support clearing an optional field back to
* null, keeping the null-vs-absent question out of the contract. The breed
* pair is the exception: providing either breedId or customBreedName
* replaces the pair as a whole (they are mutually exclusive per
* ck_pets_breed). {@code version} is mandatory — it is the optimistic lock
* the whole endpoint exists to enforce.
*/
public class UpdatePetRequest {
@NotNull(message = "version 不能为空")
@PositiveOrZero(message = "version 必须为非负整数")
private Integer version;
@Size(min = 1, max = 64, message = "宠物名称长度须在 1~64 字符")
private String name;
private UUID breedId;
@Size(min = 1, max = 64, message = "自定义品种名称长度须在 1~64 字符")
private String customBreedName;
@Pattern(regexp = "male|female|unknown", message = "性别仅支持 male/female/unknown")
private String sex;
private LocalDate birthDate;
private Boolean birthDateEstimated;
@Size(max = 64, message = "性格描述最长 64 字符")
private String personality;
@Size(max = 64, message = "芯片号最长 64 字符")
private String microchipNo;
private LocalDate sterilizedOn;
/**
* Status transition; allowed targets are active/lost/deceased/archived.
* 'deleted' is not settable through PATCH — soft delete has its own
* endpoint so its deleted_at bookkeeping (ck_pets_deleted) cannot be
* bypassed.
*/
@Pattern(regexp = "active|lost|deceased|archived",
message = "status 仅支持 active/lost/deceased/archived")
private String status;
public Integer getVersion() {
return version;
}
public void setVersion(Integer version) {
this.version = version;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public UUID getBreedId() {
return breedId;
}
public void setBreedId(UUID breedId) {
this.breedId = breedId;
}
public String getCustomBreedName() {
return customBreedName;
}
public void setCustomBreedName(String customBreedName) {
this.customBreedName = customBreedName;
}
public String getSex() {
return sex;
}
public void setSex(String sex) {
this.sex = sex;
}
public LocalDate getBirthDate() {
return birthDate;
}
public void setBirthDate(LocalDate birthDate) {
this.birthDate = birthDate;
}
public Boolean getBirthDateEstimated() {
return birthDateEstimated;
}
public void setBirthDateEstimated(Boolean birthDateEstimated) {
this.birthDateEstimated = birthDateEstimated;
}
public String getPersonality() {
return personality;
}
public void setPersonality(String personality) {
this.personality = personality;
}
public String getMicrochipNo() {
return microchipNo;
}
public void setMicrochipNo(String microchipNo) {
this.microchipNo = microchipNo;
}
public LocalDate getSterilizedOn() {
return sterilizedOn;
}
public void setSterilizedOn(LocalDate sterilizedOn) {
this.sterilizedOn = sterilizedOn;
}
public String getStatus() {
return status;
}
public void setStatus(String status) {
this.status = status;
}
}
@@ -0,0 +1,49 @@
package com.patbond.patbond.pet.repository;
import com.patbond.patbond.pet.dto.BreedResponse;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
/** Read-only access to the pet_health.breeds dictionary (V4 seed, D2-6). */
@Repository
public class BreedRepository {
private final JdbcClient jdbcClient;
public BreedRepository(JdbcClient jdbcClient) {
this.jdbcClient = jdbcClient;
}
public List<BreedResponse> listEnabled(String species) {
String sql = """
SELECT id, species, code, display_name
FROM pet_health.breeds
WHERE enabled
""" + (species != null ? " AND species = :species" : "") + """
ORDER BY species, sort_order, id
""";
var spec = jdbcClient.sql(sql);
if (species != null) {
spec = spec.param("species", species);
}
return spec.query((rs, rowNum) -> new BreedResponse(
rs.getObject("id", UUID.class),
rs.getString("species"),
rs.getString("code"),
rs.getString("display_name")))
.list();
}
/** @return the species of an enabled breed, empty when unknown/disabled */
public Optional<String> findEnabledSpecies(UUID breedId) {
return jdbcClient.sql(
"SELECT species FROM pet_health.breeds WHERE id = :id AND enabled")
.param("id", breedId)
.query(String.class)
.optional();
}
}
@@ -0,0 +1,144 @@
package com.patbond.patbond.pet.repository;
import com.patbond.patbond.pet.dto.PetResponse;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.stereotype.Repository;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.time.LocalDate;
import java.time.OffsetDateTime;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
/**
* pet_health.pets + pet_owners access. All reads join pet_owners on the
* calling user so a row only comes back when a relationship exists — the
* repository layer itself never exposes another user's pet.
*/
@Repository
public class PetRepository {
private static final String SELECT_PET = """
SELECT p.id, p.name, p.species, p.breed_id, b.display_name AS breed_display_name,
p.custom_breed_name, p.sex, p.birth_date, p.birth_date_estimated,
p.personality, p.microchip_no, p.sterilized_on, p.status,
po.role, p.created_at, p.updated_at, p.version
FROM pet_health.pets p
JOIN pet_health.pet_owners po ON po.pet_id = p.id AND po.user_id = :userId
LEFT JOIN pet_health.breeds b ON b.id = p.breed_id
WHERE p.status <> 'deleted'
""";
private final JdbcClient jdbcClient;
public PetRepository(JdbcClient jdbcClient) {
this.jdbcClient = jdbcClient;
}
public void insertPet(UUID petId, String name, String species, UUID breedId,
String customBreedName, String sex, LocalDate birthDate,
boolean birthDateEstimated, String personality,
String microchipNo, LocalDate sterilizedOn) {
jdbcClient.sql("""
INSERT INTO pet_health.pets
(id, name, species, breed_id, custom_breed_name, sex, birth_date,
birth_date_estimated, personality, microchip_no, sterilized_on)
VALUES (:id, :name, :species, :breedId, :customBreedName, :sex, :birthDate,
:birthDateEstimated, :personality, :microchipNo, :sterilizedOn)
""")
.param("id", petId)
.param("name", name)
.param("species", species)
.param("breedId", breedId)
.param("customBreedName", customBreedName)
.param("sex", sex)
.param("birthDate", birthDate)
.param("birthDateEstimated", birthDateEstimated)
.param("personality", personality)
.param("microchipNo", microchipNo)
.param("sterilizedOn", sterilizedOn)
.update();
}
public void insertPrimaryOwner(UUID petId, UUID userId) {
jdbcClient.sql("""
INSERT INTO pet_health.pet_owners (pet_id, user_id, role, is_primary)
VALUES (:petId, :userId, 'owner', true)
""")
.param("petId", petId)
.param("userId", userId)
.update();
}
public List<PetResponse> listByUser(UUID userId) {
return jdbcClient.sql(SELECT_PET + " ORDER BY p.created_at DESC, p.id DESC")
.param("userId", userId)
.query(PetRepository::mapPet)
.list();
}
public Optional<PetResponse> findByIdForUser(UUID petId, UUID userId) {
return jdbcClient.sql(SELECT_PET + " AND p.id = :petId")
.param("userId", userId)
.param("petId", petId)
.query(PetRepository::mapPet)
.optional();
}
/**
* Full-row optimistic-lock update: writes the merged state and bumps
* version only when the row still carries the version the caller read.
*
* @return number of rows updated — 0 means the version is stale
*/
public int updateWithVersion(UUID petId, int expectedVersion, String name, UUID breedId,
String customBreedName, String sex, LocalDate birthDate,
boolean birthDateEstimated, String personality,
String microchipNo, LocalDate sterilizedOn, String status) {
return jdbcClient.sql("""
UPDATE pet_health.pets
SET name = :name, breed_id = :breedId, custom_breed_name = :customBreedName,
sex = :sex, birth_date = :birthDate,
birth_date_estimated = :birthDateEstimated, personality = :personality,
microchip_no = :microchipNo, sterilized_on = :sterilizedOn,
status = :status, version = version + 1
WHERE id = :petId AND version = :expectedVersion AND status <> 'deleted'
""")
.param("petId", petId)
.param("expectedVersion", expectedVersion)
.param("name", name)
.param("breedId", breedId)
.param("customBreedName", customBreedName)
.param("sex", sex)
.param("birthDate", birthDate)
.param("birthDateEstimated", birthDateEstimated)
.param("personality", personality)
.param("microchipNo", microchipNo)
.param("sterilizedOn", sterilizedOn)
.param("status", status)
.update();
}
private static PetResponse mapPet(ResultSet rs, int rowNum) throws SQLException {
return new PetResponse(
rs.getObject("id", UUID.class),
rs.getString("name"),
rs.getString("species"),
rs.getObject("breed_id", UUID.class),
rs.getString("breed_display_name"),
rs.getString("custom_breed_name"),
rs.getString("sex"),
rs.getObject("birth_date", LocalDate.class),
rs.getBoolean("birth_date_estimated"),
rs.getString("personality"),
rs.getString("microchip_no"),
rs.getObject("sterilized_on", LocalDate.class),
rs.getString("status"),
rs.getString("role"),
rs.getObject("created_at", OffsetDateTime.class),
rs.getObject("updated_at", OffsetDateTime.class),
rs.getInt("version"));
}
}
@@ -0,0 +1,75 @@
package com.patbond.patbond.pet.security;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.patbond.patbond.common.error.BusinessException;
import com.patbond.patbond.common.error.ErrorCode;
import com.patbond.patbond.common.response.ApiResponse;
import io.jsonwebtoken.Claims;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.MediaType;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.util.UUID;
/**
* Bearer authentication for /api/v1/** pet endpoints. Verifies the RS256
* signature locally with the auth service's public key and exposes the
* authenticated user id as a request attribute. Missing, forged or expired
* tokens all answer 401/40101 without detail.
*/
public class BearerAuthFilter extends OncePerRequestFilter {
/** Request attribute holding the authenticated user's UUID. */
public static final String USER_ID_ATTRIBUTE = "patbond.authenticatedUserId";
private static final Logger log = LoggerFactory.getLogger(BearerAuthFilter.class);
private final JwtVerifier jwtVerifier;
private final ObjectMapper objectMapper;
public BearerAuthFilter(JwtVerifier jwtVerifier, ObjectMapper objectMapper) {
this.jwtVerifier = jwtVerifier;
this.objectMapper = objectMapper;
}
@Override
protected boolean shouldNotFilter(HttpServletRequest request) {
return !request.getRequestURI().startsWith("/api/v1/");
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String header = request.getHeader("Authorization");
if (header == null || !header.startsWith("Bearer ")) {
reject(response, ErrorCode.TOKEN_INVALID);
return;
}
try {
Claims claims = jwtVerifier.verify(header.substring("Bearer ".length()).trim());
request.setAttribute(USER_ID_ATTRIBUTE, UUID.fromString(claims.getSubject()));
} catch (BusinessException e) {
reject(response, ErrorCode.TOKEN_INVALID);
return;
} catch (IllegalStateException | IllegalArgumentException e) {
log.error("Access token verification unavailable: {}", e.getMessage());
reject(response, ErrorCode.INTERNAL_ERROR);
return;
}
filterChain.doFilter(request, response);
}
private void reject(HttpServletResponse response, ErrorCode errorCode) throws IOException {
response.setStatus(errorCode.getHttpStatus());
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.setCharacterEncoding("UTF-8");
objectMapper.writeValue(response.getWriter(),
ApiResponse.failure(errorCode.getCode(), errorCode.getDefaultMessage()));
}
}
@@ -0,0 +1,41 @@
package com.patbond.patbond.pet.security;
import com.patbond.patbond.common.error.BusinessException;
import com.patbond.patbond.common.error.ErrorCode;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.JwtException;
import io.jsonwebtoken.JwtParser;
import io.jsonwebtoken.Jwts;
/**
* Verifies RS256 access tokens issued by patbond-auth against the configured
* public key. The key is optional at startup so contexts that never serve
* protected routes (most tests) can boot without one; any verification
* attempt without a key fails loudly as a server misconfiguration instead of
* being reported to the client as an authentication problem.
*/
public class JwtVerifier {
private final JwtParser parser;
public JwtVerifier(String publicKeyLocation) {
this.parser = publicKeyLocation == null || publicKeyLocation.isBlank()
? null
: Jwts.parser().verifyWith(RsaPublicKeyLoader.load(publicKeyLocation)).build();
}
/**
* @return the verified claims
* @throws BusinessException 40101 when the token is forged, malformed or expired
*/
public Claims verify(String token) {
if (parser == null) {
throw new IllegalStateException("patbond.jwt.public-key 未配置,无法校验 access token");
}
try {
return parser.parseSignedClaims(token).getPayload();
} catch (JwtException | IllegalArgumentException e) {
throw new BusinessException(ErrorCode.TOKEN_INVALID);
}
}
}
@@ -0,0 +1,43 @@
package com.patbond.patbond.pet.security;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.KeyFactory;
import java.security.NoSuchAlgorithmException;
import java.security.PublicKey;
import java.security.spec.InvalidKeySpecException;
import java.security.spec.X509EncodedKeySpec;
import java.util.Base64;
/**
* Loads an RSA public key for JWT signature verification. Accepts either
* inline PEM or a filesystem path; the inline form (environment variable
* PATBOND_JWT_PUBLIC_KEY starting with -----BEGIN) is production's choice
* because mounted secrets beat files-in-the-image.
*/
public final class RsaPublicKeyLoader {
private RsaPublicKeyLoader() {
}
public static PublicKey load(String pemOrPath) {
String pem = pemOrPath.startsWith("-----BEGIN") ? pemOrPath : readFile(pemOrPath);
String stripped = pem.replaceAll("-----BEGIN PUBLIC KEY-----|-----END PUBLIC KEY-----", "")
.replaceAll("\\s", "");
byte[] decoded = Base64.getDecoder().decode(stripped);
try {
return KeyFactory.getInstance("RSA").generatePublic(new X509EncodedKeySpec(decoded));
} catch (NoSuchAlgorithmException | InvalidKeySpecException e) {
throw new IllegalArgumentException("Invalid RSA public key", e);
}
}
private static String readFile(String path) {
try {
return Files.readString(Path.of(path));
} catch (IOException e) {
throw new IllegalArgumentException("Cannot read public key from " + path, e);
}
}
}
@@ -0,0 +1,166 @@
package com.patbond.patbond.pet.service;
import com.patbond.patbond.common.error.BusinessException;
import com.patbond.patbond.common.error.ErrorCode;
import com.patbond.patbond.pet.access.AccessLevel;
import com.patbond.patbond.pet.access.PetAccessService;
import com.patbond.patbond.pet.dto.CreatePetRequest;
import com.patbond.patbond.pet.dto.PetResponse;
import com.patbond.patbond.pet.dto.UpdatePetRequest;
import com.patbond.patbond.pet.repository.BreedRepository;
import com.patbond.patbond.pet.repository.PetRepository;
import com.patbond.patbond.pet.support.UuidV7;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.UUID;
/**
* Pet profile use-cases. The application-level rules here deliberately
* mirror the V3 database constraints (ck_pets_breed, ck_pets_status,
* uq_pets_microchip) so clients get a stable business error instead of a
* constraint-violation 500 — the constraints stay as the last line of
* defense.
*/
@Service
public class PetService {
private final PetRepository petRepository;
private final BreedRepository breedRepository;
private final PetAccessService petAccessService;
public PetService(PetRepository petRepository, BreedRepository breedRepository,
PetAccessService petAccessService) {
this.petRepository = petRepository;
this.breedRepository = breedRepository;
this.petAccessService = petAccessService;
}
/**
* Creates a pet and its primary-owner relationship in one transaction
* (ADR-015: the creator is the primary owner; invitations come later).
*/
@Transactional
public PetResponse create(UUID userId, CreatePetRequest request) {
validateBreedPair(request.getBreedId(), request.getCustomBreedName(), request.getSpecies());
UUID petId = UuidV7.generate();
try {
petRepository.insertPet(
petId,
request.getName().trim(),
request.getSpecies(),
request.getBreedId(),
trimOrNull(request.getCustomBreedName()),
request.getSex() == null ? "unknown" : request.getSex(),
request.getBirthDate(),
Boolean.TRUE.equals(request.getBirthDateEstimated()),
trimOrNull(request.getPersonality()),
trimOrNull(request.getMicrochipNo()),
request.getSterilizedOn());
} catch (DuplicateKeyException e) {
throw new BusinessException(ErrorCode.MICROCHIP_EXISTS);
}
petRepository.insertPrimaryOwner(petId, userId);
return petRepository.findByIdForUser(petId, userId)
.orElseThrow(() -> new BusinessException(ErrorCode.INTERNAL_ERROR));
}
public List<PetResponse> list(UUID userId) {
return petRepository.listByUser(userId);
}
public PetResponse get(UUID userId, UUID petId) {
petAccessService.require(userId, petId, AccessLevel.READ);
return petRepository.findByIdForUser(petId, userId)
.orElseThrow(() -> new BusinessException(ErrorCode.PET_NOT_FOUND));
}
/**
* Partial update under the optimistic lock. Merge happens in Java (read
* current row → overlay non-null request fields → single conditional
* UPDATE), so a stale {@code version} loses even when the write would be
* a no-op.
*/
@Transactional
public PetResponse update(UUID userId, UUID petId, UpdatePetRequest request) {
petAccessService.require(userId, petId, AccessLevel.MANAGE);
PetResponse current = petRepository.findByIdForUser(petId, userId)
.orElseThrow(() -> new BusinessException(ErrorCode.PET_NOT_FOUND));
UUID breedId = current.breedId();
String customBreedName = current.customBreedName();
if (request.getBreedId() != null || request.getCustomBreedName() != null) {
// The pair is replaced as a whole: sending one side clears the other.
breedId = request.getBreedId();
customBreedName = trimOrNull(request.getCustomBreedName());
validateBreedPair(breedId, customBreedName, current.species());
}
String name = request.getName() != null ? request.getName().trim() : current.name();
String sex = request.getSex() != null ? request.getSex() : current.sex();
String status = request.getStatus() != null ? request.getStatus() : current.status();
int updated;
try {
updated = petRepository.updateWithVersion(
petId,
request.getVersion(),
name,
breedId,
customBreedName,
sex,
request.getBirthDate() != null ? request.getBirthDate() : current.birthDate(),
request.getBirthDateEstimated() != null
? request.getBirthDateEstimated() : current.birthDateEstimated(),
request.getPersonality() != null
? trimOrNull(request.getPersonality()) : current.personality(),
request.getMicrochipNo() != null
? trimOrNull(request.getMicrochipNo()) : current.microchipNo(),
request.getSterilizedOn() != null
? request.getSterilizedOn() : current.sterilizedOn(),
status);
} catch (DuplicateKeyException e) {
throw new BusinessException(ErrorCode.MICROCHIP_EXISTS);
}
if (updated == 0) {
// The pet was visible a moment ago (require() passed), so a
// missed conditional update means the version is stale.
throw new BusinessException(ErrorCode.VERSION_CONFLICT);
}
return petRepository.findByIdForUser(petId, userId)
.orElseThrow(() -> new BusinessException(ErrorCode.INTERNAL_ERROR));
}
/**
* Exactly one of breedId / customBreedName (ck_pets_breed), and a chosen
* dictionary breed must exist, be enabled and match the pet's species
* (the composite FK would reject the mismatch — this turns it into a
* 40000 with a readable message).
*/
private void validateBreedPair(UUID breedId, String customBreedName, String species) {
boolean hasBreed = breedId != null;
boolean hasCustom = customBreedName != null && !customBreedName.isBlank();
if (hasBreed == hasCustom) {
throw new BusinessException(ErrorCode.VALIDATION_ERROR,
"breedId 与 customBreedName 必须二选一");
}
if (hasBreed) {
String breedSpecies = breedRepository.findEnabledSpecies(breedId)
.orElseThrow(() -> new BusinessException(ErrorCode.VALIDATION_ERROR,
"品种不存在或已停用"));
if (!breedSpecies.equals(species)) {
throw new BusinessException(ErrorCode.VALIDATION_ERROR, "品种与物种不匹配");
}
}
}
private static String trimOrNull(String value) {
if (value == null) {
return null;
}
String trimmed = value.trim();
return trimmed.isEmpty() ? null : trimmed;
}
}
@@ -0,0 +1,31 @@
package com.patbond.patbond.pet.support;
import java.security.SecureRandom;
import java.util.UUID;
/**
* Application-side UUIDv7 generator (RFC 9562): 48-bit Unix millisecond
* timestamp, version/variant bits, 74 random bits. Time-ordered values keep
* B-tree page churn low on uuid primary keys; the database DEFAULT
* gen_random_uuid() remains the fallback for rows not inserted through the
* application. Same implementation as patbond-user's — the services are
* independently deployed and patbond-common stays contract-only, so each
* carries its own copy of this 20-line utility.
*/
public final class UuidV7 {
private static final SecureRandom RANDOM = new SecureRandom();
private UuidV7() {
}
public static UUID generate() {
long timestampMs = System.currentTimeMillis();
long randA = RANDOM.nextLong() & 0x0FFFL;
long randB = RANDOM.nextLong() & 0x3FFFFFFFFFFFFFFFL;
long msb = (timestampMs << 16) | 0x7000L | randA;
long lsb = 0x8000000000000000L | randB;
return new UUID(msb, lsb);
}
}
@@ -3,6 +3,7 @@ package com.patbond.patbond.pet.web;
import com.patbond.patbond.common.error.BusinessException; import com.patbond.patbond.common.error.BusinessException;
import com.patbond.patbond.common.error.ErrorCode; import com.patbond.patbond.common.error.ErrorCode;
import com.patbond.patbond.common.response.ApiResponse; import com.patbond.patbond.common.response.ApiResponse;
import jakarta.validation.ConstraintViolationException;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
@@ -11,6 +12,7 @@ import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice; import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.method.annotation.HandlerMethodValidationException;
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
import org.springframework.web.servlet.resource.NoResourceFoundException; import org.springframework.web.servlet.resource.NoResourceFoundException;
@@ -40,7 +42,8 @@ public class GlobalExceptionHandler {
return failure(ErrorCode.VALIDATION_ERROR, message); return failure(ErrorCode.VALIDATION_ERROR, message);
} }
@ExceptionHandler({HttpMessageNotReadableException.class, MethodArgumentTypeMismatchException.class}) @ExceptionHandler({HttpMessageNotReadableException.class, MethodArgumentTypeMismatchException.class,
ConstraintViolationException.class, HandlerMethodValidationException.class})
public ResponseEntity<ApiResponse<Void>> handleMalformedRequest(Exception e) { public ResponseEntity<ApiResponse<Void>> handleMalformedRequest(Exception e) {
return failure(ErrorCode.VALIDATION_ERROR, ErrorCode.VALIDATION_ERROR.getDefaultMessage()); return failure(ErrorCode.VALIDATION_ERROR, ErrorCode.VALIDATION_ERROR.getDefaultMessage());
} }
@@ -12,5 +12,10 @@ spring:
username: ${PATBOND_DB_USER:patbond} username: ${PATBOND_DB_USER:patbond}
password: ${PATBOND_DB_PASSWORD:patbond} password: ${PATBOND_DB_PASSWORD:patbond}
# M2 第一波骨架:暂无 /api/v1 业务端点,故尚无 JWT 公钥配置。第二波接口 # M2 第二波:/api/v1/** 业务端点已接 RS256 校验,与 patbond-user 同一约定。
# 落地时将与 patbond-user 同一约定接入 RS256 校验(PATBOND_JWT_PUBLIC_KEY)。 patbond:
jwt:
# RS256 公钥,用于本地校验 patbond-auth 签发的 access token。
# 值可以是 PEM 文件路径,也可以是内联 PEM 内容(以 -----BEGIN 开头)。
# 私钥只给 patbond-auth,绝不入库。
public-key: ${PATBOND_JWT_PUBLIC_KEY:}
@@ -9,9 +9,11 @@ import org.testcontainers.utility.DockerImageName;
/** /**
* Shared Testcontainers setup: a disposable postgres:18 (the production * Shared Testcontainers setup: a disposable postgres:18 (the production
* target version) wired into the Spring context via @ServiceConnection. * target version) wired into the Spring context via @ServiceConnection.
* This module carries no Flyway (the single migration chain is owned by * The production module carries no Flyway (the single migration chain is
* patbond-user), so tests here run against a clean database — schema-level * owned by patbond-user), but the TEST classpath adds patbond-user's jar
* assertions about pet_health live in patbond-user's migration tests. * plus Flyway, so Boot applies the full V1..V4 chain — including the
* pet_health schema these tests exercise — to the fresh container exactly
* as the shared database gets it in production.
*/ */
@TestConfiguration(proxyBeanMethods = false) @TestConfiguration(proxyBeanMethods = false)
public class TestcontainersConfiguration { public class TestcontainersConfiguration {
@@ -0,0 +1,207 @@
package com.patbond.patbond.pet.access;
import com.jayway.jsonpath.JsonPath;
import com.patbond.patbond.pet.support.PetIntegrationTestSupport;
import com.patbond.patbond.pet.support.TestJwtKeys;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.MvcResult;
import java.time.Duration;
import java.util.UUID;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/**
* The frozen permission semantics (T2-03, contract input for T2-09):
*
* <ul>
* <li>No pet_owners relationship → 404/40401 on every verb — a stranger
* cannot distinguish someone else's pet from a nonexistent id
* (anti-enumeration, iteration-2/02 P7).</li>
* <li>viewer: reads succeed, writes → 403/40300.</li>
* <li>caregiver: reads succeed, profile PATCH (MANAGE) → 403/40300; the
* WRITE level is exercised by T2-04+ sub-resources.</li>
* <li>owner: everything.</li>
* <li>Unauthenticated /api/v1/** → 401/40101 (filter, before any
* permission logic).</li>
* </ul>
*
* <p>Roles are seeded directly into pet_owners (ADR-015: invitation flow is
* post-M2; T2-10 mandates test-data construction so the permission code
* never goes unverified).
*/
class PetPermissionIntegrationTest extends PetIntegrationTestSupport {
@Autowired
private MockMvc mockMvc;
private String createPetAs(UUID ownerId) throws Exception {
MvcResult result = mockMvc.perform(post("/api/v1/pets")
.header("Authorization", "Bearer " + tokenFor(ownerId))
.contentType(MediaType.APPLICATION_JSON)
.content("{\"name\":\"权限猫\",\"species\":\"cat\",\"customBreedName\":\"狸花\"}"))
.andExpect(status().isCreated())
.andReturn();
return JsonPath.read(result.getResponse().getContentAsString(), "$.data.id");
}
// ---- 无关系用户:一律 404(防枚举) ----
@Test
void strangerGetsSame404AsNonexistentPet() throws Exception {
UUID owner = newUser("owner_stranger_read");
UUID stranger = newUser("stranger_read");
String petId = createPetAs(owner);
mockMvc.perform(get("/api/v1/pets/{id}", petId)
.header("Authorization", "Bearer " + tokenFor(stranger)))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.code").value(40401));
// 与真正不存在的 id 响应完全一致 —— 无法据此探测 id 是否有效
mockMvc.perform(get("/api/v1/pets/{id}", UUID.randomUUID())
.header("Authorization", "Bearer " + tokenFor(stranger)))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.code").value(40401));
}
@Test
void strangerPatchAlsoReturns404() throws Exception {
UUID owner = newUser("owner_stranger_write");
UUID stranger = newUser("stranger_write");
String petId = createPetAs(owner);
mockMvc.perform(patch("/api/v1/pets/{id}", petId)
.header("Authorization", "Bearer " + tokenFor(stranger))
.contentType(MediaType.APPLICATION_JSON)
.content("{\"version\":0,\"name\":\"抢注\"}"))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.code").value(40401));
}
@Test
void strangerListDoesNotContainOthersPets() throws Exception {
UUID owner = newUser("owner_list_iso");
UUID stranger = newUser("stranger_list_iso");
createPetAs(owner);
mockMvc.perform(get("/api/v1/pets")
.header("Authorization", "Bearer " + tokenFor(stranger)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data").isEmpty());
}
// ---- viewer:只读 ----
@Test
void viewerCanReadButNotWrite() throws Exception {
UUID owner = newUser("owner_viewer_case");
UUID viewer = newUser("viewer_case");
String petId = createPetAs(owner);
grantRole(UUID.fromString(petId), viewer, "viewer");
mockMvc.perform(get("/api/v1/pets/{id}", petId)
.header("Authorization", "Bearer " + tokenFor(viewer)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.myRole").value("viewer"));
mockMvc.perform(get("/api/v1/pets")
.header("Authorization", "Bearer " + tokenFor(viewer)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data[0].id").value(petId));
// 可见但越权 → 403(与无关系的 404 语义区分开)
mockMvc.perform(patch("/api/v1/pets/{id}", petId)
.header("Authorization", "Bearer " + tokenFor(viewer))
.contentType(MediaType.APPLICATION_JSON)
.content("{\"version\":0,\"name\":\"越权改名\"}"))
.andExpect(status().isForbidden())
.andExpect(jsonPath("$.code").value(40300));
}
// ---- caregiver:可读、可写记录,但不可改档案 ----
@Test
void caregiverCanReadButNotManageProfile() throws Exception {
UUID owner = newUser("owner_cg_case");
UUID caregiver = newUser("caregiver_case");
String petId = createPetAs(owner);
grantRole(UUID.fromString(petId), caregiver, "caregiver");
mockMvc.perform(get("/api/v1/pets/{id}", petId)
.header("Authorization", "Bearer " + tokenFor(caregiver)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.myRole").value("caregiver"));
// 宠物档案本身是 MANAGE 级:caregiver 的写权限只覆盖健康记录子资源
mockMvc.perform(patch("/api/v1/pets/{id}", petId)
.header("Authorization", "Bearer " + tokenFor(caregiver))
.contentType(MediaType.APPLICATION_JSON)
.content("{\"version\":0,\"name\":\"照护人改名\"}"))
.andExpect(status().isForbidden())
.andExpect(jsonPath("$.code").value(40300));
}
// ---- 权限即时性:撤销关系立刻生效 ----
@Test
void revokedViewerImmediatelyLosesAccess() throws Exception {
UUID owner = newUser("owner_revoke");
UUID viewer = newUser("viewer_revoke");
String petId = createPetAs(owner);
grantRole(UUID.fromString(petId), viewer, "viewer");
mockMvc.perform(get("/api/v1/pets/{id}", petId)
.header("Authorization", "Bearer " + tokenFor(viewer)))
.andExpect(status().isOk());
jdbcClient.sql("DELETE FROM pet_health.pet_owners WHERE pet_id = :petId AND user_id = :userId")
.param("petId", UUID.fromString(petId))
.param("userId", viewer)
.update();
// 每请求实时查 pet_owners,无缓存:降级为无关系 → 404
mockMvc.perform(get("/api/v1/pets/{id}", petId)
.header("Authorization", "Bearer " + tokenFor(viewer)))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.code").value(40401));
}
// ---- 未认证:401 ----
@Test
void missingTokenReturns401() throws Exception {
mockMvc.perform(get("/api/v1/pets"))
.andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.code").value(40101));
}
@Test
void tokenSignedByWrongKeyReturns401() throws Exception {
UUID user = newUser("wrong_key_user");
String forged = TestJwtKeys.accessToken(
TestJwtKeys.WRONG_KEY_PAIR.getPrivate(), user, Duration.ofMinutes(15));
mockMvc.perform(get("/api/v1/pets")
.header("Authorization", "Bearer " + forged))
.andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.code").value(40101));
}
@Test
void expiredTokenReturns401() throws Exception {
UUID user = newUser("expired_user");
String expired = TestJwtKeys.accessToken(
TestJwtKeys.KEY_PAIR.getPrivate(), user, Duration.ofMinutes(-5));
mockMvc.perform(get("/api/v1/pets")
.header("Authorization", "Bearer " + expired))
.andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.code").value(40101));
}
}
@@ -0,0 +1,322 @@
package com.patbond.patbond.pet.controller;
import com.jayway.jsonpath.JsonPath;
import com.patbond.patbond.pet.support.PetIntegrationTestSupport;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.MvcResult;
import java.util.UUID;
import static org.hamcrest.Matchers.greaterThan;
import static org.hamcrest.Matchers.hasSize;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/**
* T2-03 acceptance: create → list → detail → update → archive against real
* PostgreSQL, plus the six mandated paths (success, validation error, not
* found, no permission, concurrent conflict, idempotency/duplicate) and the
* three-role permission matrix (T2-10: roles constructed as test data,
* ADR-015).
*/
class PetCrudIntegrationTest extends PetIntegrationTestSupport {
@Autowired
private MockMvc mockMvc;
private String createPet(UUID ownerId, String name) throws Exception {
MvcResult result = mockMvc.perform(post("/api/v1/pets")
.header("Authorization", "Bearer " + tokenFor(ownerId))
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"name":"%s","species":"cat","customBreedName":"狸花猫"}
""".formatted(name)))
.andExpect(status().isCreated())
.andReturn();
return JsonPath.read(result.getResponse().getContentAsString(), "$.data.id");
}
// ---- 成功路径 ----
@Test
void createListDetailUpdateArchiveFullChain() throws Exception {
UUID owner = newUser("chain_owner");
UUID breedId = anyBreedId("dog");
MvcResult created = mockMvc.perform(post("/api/v1/pets")
.header("Authorization", "Bearer " + tokenFor(owner))
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"name":"旺财","species":"dog","breedId":"%s",
"sex":"male","birthDate":"2024-05-01","birthDateEstimated":true,
"personality":"活泼","microchipNo":"chip-chain-001"}
""".formatted(breedId)))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.name").value("旺财"))
.andExpect(jsonPath("$.data.species").value("dog"))
.andExpect(jsonPath("$.data.breedId").value(breedId.toString()))
.andExpect(jsonPath("$.data.breedDisplayName").isNotEmpty())
.andExpect(jsonPath("$.data.customBreedName").isEmpty())
.andExpect(jsonPath("$.data.status").value("active"))
.andExpect(jsonPath("$.data.myRole").value("owner"))
.andExpect(jsonPath("$.data.version").value(0))
.andReturn();
String petId = JsonPath.read(created.getResponse().getContentAsString(), "$.data.id");
// 创建者自动成为 primary ownerpet_owners 落库校验)
Boolean isPrimary = jdbcClient.sql("""
SELECT is_primary FROM pet_health.pet_owners
WHERE pet_id = :petId AND user_id = :userId AND role = 'owner'
""")
.param("petId", UUID.fromString(petId))
.param("userId", owner)
.query(Boolean.class)
.single();
org.assertj.core.api.Assertions.assertThat(isPrimary).isTrue();
mockMvc.perform(get("/api/v1/pets")
.header("Authorization", "Bearer " + tokenFor(owner)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data", hasSize(1)))
.andExpect(jsonPath("$.data[0].id").value(petId));
mockMvc.perform(get("/api/v1/pets/{id}", petId)
.header("Authorization", "Bearer " + tokenFor(owner)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.id").value(petId))
.andExpect(jsonPath("$.data.birthDate").value("2024-05-01"))
.andExpect(jsonPath("$.data.myRole").value("owner"));
mockMvc.perform(patch("/api/v1/pets/{id}", petId)
.header("Authorization", "Bearer " + tokenFor(owner))
.contentType(MediaType.APPLICATION_JSON)
.content("{\"version\":0,\"name\":\"旺财二世\",\"personality\":\"沉稳\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.name").value("旺财二世"))
.andExpect(jsonPath("$.data.personality").value("沉稳"))
// 未提交的字段保持不变(部分更新语义)
.andExpect(jsonPath("$.data.birthDate").value("2024-05-01"))
.andExpect(jsonPath("$.data.version").value(1));
// 归档(D2-7:前端首版只出归档入口)
mockMvc.perform(patch("/api/v1/pets/{id}", petId)
.header("Authorization", "Bearer " + tokenFor(owner))
.contentType(MediaType.APPLICATION_JSON)
.content("{\"version\":1,\"status\":\"archived\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.status").value("archived"))
.andExpect(jsonPath("$.data.version").value(2));
// 归档后仍可见(软删除才隐藏)
mockMvc.perform(get("/api/v1/pets/{id}", petId)
.header("Authorization", "Bearer " + tokenFor(owner)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.status").value("archived"));
}
@Test
void breedsDictionaryFiltersBySpecies() throws Exception {
UUID user = newUser("breeds_user");
mockMvc.perform(get("/api/v1/breeds").param("species", "cat")
.header("Authorization", "Bearer " + tokenFor(user)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data", hasSize(greaterThan(0))))
.andExpect(jsonPath("$.data[?(@.species != 'cat')]", hasSize(0)))
.andExpect(jsonPath("$.data[0].displayName").isNotEmpty());
mockMvc.perform(get("/api/v1/breeds")
.header("Authorization", "Bearer " + tokenFor(user)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data[?(@.species == 'dog')]", hasSize(greaterThan(0))))
.andExpect(jsonPath("$.data[?(@.species == 'cat')]", hasSize(greaterThan(0))));
}
// ---- 参数错误路径 ----
@Test
void createWithBothBreedAndCustomNameIsRejected() throws Exception {
UUID user = newUser("breed_both");
mockMvc.perform(post("/api/v1/pets")
.header("Authorization", "Bearer " + tokenFor(user))
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"name":"小白","species":"dog","breedId":"%s","customBreedName":"串串"}
""".formatted(anyBreedId("dog"))))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value(40000));
}
@Test
void createWithNeitherBreedNorCustomNameIsRejected() throws Exception {
UUID user = newUser("breed_neither");
mockMvc.perform(post("/api/v1/pets")
.header("Authorization", "Bearer " + tokenFor(user))
.contentType(MediaType.APPLICATION_JSON)
.content("{\"name\":\"小白\",\"species\":\"dog\"}"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value(40000));
}
@Test
void createWithSpeciesMismatchedBreedIsRejected() throws Exception {
UUID user = newUser("breed_mismatch");
mockMvc.perform(post("/api/v1/pets")
.header("Authorization", "Bearer " + tokenFor(user))
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"name":"错配","species":"dog","breedId":"%s"}
""".formatted(anyBreedId("cat"))))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value(40000));
}
@Test
void createWithInvalidSpeciesIsRejected() throws Exception {
UUID user = newUser("bad_species");
mockMvc.perform(post("/api/v1/pets")
.header("Authorization", "Bearer " + tokenFor(user))
.contentType(MediaType.APPLICATION_JSON)
.content("{\"name\":\"\",\"species\":\"dragon\",\"customBreedName\":\"东方龙\"}"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value(40000));
}
@Test
void patchWithoutVersionIsRejected() throws Exception {
UUID owner = newUser("patch_nover");
String petId = createPet(owner, "无版本");
mockMvc.perform(patch("/api/v1/pets/{id}", petId)
.header("Authorization", "Bearer " + tokenFor(owner))
.contentType(MediaType.APPLICATION_JSON)
.content("{\"name\":\"改名\"}"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value(40000));
}
@Test
void patchCannotSetDeletedStatus() throws Exception {
UUID owner = newUser("patch_del");
String petId = createPet(owner, "禁删");
// 软删除不走 PATCHck_pets_deleted 的 deleted_at 记账不能被绕过)
mockMvc.perform(patch("/api/v1/pets/{id}", petId)
.header("Authorization", "Bearer " + tokenFor(owner))
.contentType(MediaType.APPLICATION_JSON)
.content("{\"version\":0,\"status\":\"deleted\"}"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value(40000));
}
@Test
void breedsWithInvalidSpeciesParamIsRejected() throws Exception {
UUID user = newUser("breeds_bad");
mockMvc.perform(get("/api/v1/breeds").param("species", "bird")
.header("Authorization", "Bearer " + tokenFor(user)))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value(40000));
}
// ---- 资源不存在路径 ----
@Test
void getUnknownPetReturns404() throws Exception {
UUID user = newUser("get_unknown");
mockMvc.perform(get("/api/v1/pets/{id}", UUID.randomUUID())
.header("Authorization", "Bearer " + tokenFor(user)))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.code").value(40401));
}
@Test
void patchUnknownPetReturns404() throws Exception {
UUID user = newUser("patch_unknown");
mockMvc.perform(patch("/api/v1/pets/{id}", UUID.randomUUID())
.header("Authorization", "Bearer " + tokenFor(user))
.contentType(MediaType.APPLICATION_JSON)
.content("{\"version\":0,\"name\":\"改名\"}"))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.code").value(40401));
}
// ---- 并发冲突路径 ----
@Test
void staleVersionReturns409() throws Exception {
UUID owner = newUser("conflict_owner");
String petId = createPet(owner, "冲突猫");
mockMvc.perform(patch("/api/v1/pets/{id}", petId)
.header("Authorization", "Bearer " + tokenFor(owner))
.contentType(MediaType.APPLICATION_JSON)
.content("{\"version\":0,\"name\":\"先到先得\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.version").value(1));
// 第二个客户端仍持有 version=0 —— 明确冲突,不静默覆盖
mockMvc.perform(patch("/api/v1/pets/{id}", petId)
.header("Authorization", "Bearer " + tokenFor(owner))
.contentType(MediaType.APPLICATION_JSON)
.content("{\"version\":0,\"name\":\"后到冲突\"}"))
.andExpect(status().isConflict())
.andExpect(jsonPath("$.code").value(40902));
mockMvc.perform(get("/api/v1/pets/{id}", petId)
.header("Authorization", "Bearer " + tokenFor(owner)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.name").value("先到先得"));
}
// ---- 幂等/重复路径 ----
@Test
void duplicateMicrochipReturns409() throws Exception {
UUID owner = newUser("chip_owner");
mockMvc.perform(post("/api/v1/pets")
.header("Authorization", "Bearer " + tokenFor(owner))
.contentType(MediaType.APPLICATION_JSON)
.content("{\"name\":\"芯片一号\",\"species\":\"cat\",\"customBreedName\":\"狸花\",\"microchipNo\":\"CHIP-DUP-42\"}"))
.andExpect(status().isCreated());
// uq_pets_microchip:重复登记同一芯片号是明确业务冲突,而非 500
mockMvc.perform(post("/api/v1/pets")
.header("Authorization", "Bearer " + tokenFor(owner))
.contentType(MediaType.APPLICATION_JSON)
.content("{\"name\":\"芯片二号\",\"species\":\"cat\",\"customBreedName\":\"狸花\",\"microchipNo\":\"CHIP-DUP-42\"}"))
.andExpect(status().isConflict())
.andExpect(jsonPath("$.code").value(40903));
}
@Test
void retriedPatchWithSameVersionConflictsInsteadOfDoubleApplying() throws Exception {
UUID owner = newUser("retry_owner");
String petId = createPet(owner, "重试猫");
String body = "{\"version\":0,\"name\":\"重试后的名字\"}";
mockMvc.perform(patch("/api/v1/pets/{id}", petId)
.header("Authorization", "Bearer " + tokenFor(owner))
.contentType(MediaType.APPLICATION_JSON)
.content(body))
.andExpect(status().isOk());
// 客户端重发同一请求(如超时重试):version 已消耗,返回 409 而非重复生效;
// 客户端收到 409 后刷新即可发现更新其实已成功 —— 乐观锁即幂等兜底。
mockMvc.perform(patch("/api/v1/pets/{id}", petId)
.header("Authorization", "Bearer " + tokenFor(owner))
.contentType(MediaType.APPLICATION_JSON)
.content(body))
.andExpect(status().isConflict())
.andExpect(jsonPath("$.code").value(40902));
Integer version = jdbcClient.sql("SELECT version FROM pet_health.pets WHERE id = :id")
.param("id", UUID.fromString(petId))
.query(Integer.class)
.single();
org.assertj.core.api.Assertions.assertThat(version).isEqualTo(1);
}
}
@@ -0,0 +1,75 @@
package com.patbond.patbond.pet.support;
import com.patbond.patbond.pet.TestcontainersConfiguration;
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.context.annotation.Import;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import java.time.Duration;
import java.util.UUID;
/**
* Base for pet-domain integration tests: Testcontainers postgres:18 with the
* full V1..V4 migration chain (pulled from patbond-user's jar on the test
* classpath), MockMvc behind the real BearerAuthFilter, and helpers to mint
* users, tokens and pet_owners rows.
*
* <p>Roles are seeded by writing pet_owners directly (ADR-015 / T2-10: the
* invitation flow is out of M2, so caregiver/viewer scenarios are
* constructed as test data — this is the sanctioned way to keep the
* permission paths verified).
*/
@SpringBootTest
@AutoConfigureMockMvc
@Import(TestcontainersConfiguration.class)
public abstract class PetIntegrationTestSupport {
@Autowired
protected JdbcClient jdbcClient;
@DynamicPropertySource
static void jwtPublicKey(DynamicPropertyRegistry registry) {
registry.add("patbond.jwt.public-key", TestJwtKeys::publicPem);
}
/** Inserts an identity.users row and returns its id. */
protected UUID newUser(String username) {
UUID id = UuidV7.generate();
jdbcClient.sql("INSERT INTO identity.users (id, username) VALUES (:id, :username)")
.param("id", id)
.param("username", username)
.update();
return id;
}
/** A valid access token for the user, signed like patbond-auth does. */
protected static String tokenFor(UUID userId) {
return TestJwtKeys.accessToken(TestJwtKeys.KEY_PAIR.getPrivate(), userId,
Duration.ofMinutes(15));
}
/** Adds a non-primary pet_owners row (test-data stand-in for invitations). */
protected void grantRole(UUID petId, UUID userId, String role) {
jdbcClient.sql("""
INSERT INTO pet_health.pet_owners (pet_id, user_id, role, is_primary)
VALUES (:petId, :userId, :role, false)
""")
.param("petId", petId)
.param("userId", userId)
.param("role", role)
.update();
}
/** Any enabled breed id of the given species, from the V4 seed. */
protected UUID anyBreedId(String species) {
return jdbcClient.sql(
"SELECT id FROM pet_health.breeds WHERE species = :species AND enabled LIMIT 1")
.param("species", species)
.query(UUID.class)
.single();
}
}
@@ -0,0 +1,59 @@
package com.patbond.patbond.pet.support;
import io.jsonwebtoken.Jwts;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.NoSuchAlgorithmException;
import java.security.PrivateKey;
import java.time.Duration;
import java.time.Instant;
import java.util.Base64;
import java.util.Date;
import java.util.UUID;
/**
* Runtime-generated RSA material for JWT tests. Nothing here is committed
* key material (git-workflow: no credentials in the repository) — every test
* run mints a fresh pair and injects the public key via
* {@code @DynamicPropertySource}.
*/
public final class TestJwtKeys {
public static final KeyPair KEY_PAIR = generate();
/** A second pair, for signing tokens the service must reject. */
public static final KeyPair WRONG_KEY_PAIR = generate();
private TestJwtKeys() {
}
public static String publicPem() {
return "-----BEGIN PUBLIC KEY-----\n"
+ Base64.getEncoder().encodeToString(KEY_PAIR.getPublic().getEncoded())
+ "\n-----END PUBLIC KEY-----";
}
/** Signs an access token the way patbond-auth does (sub/jti/sid/iat/exp). */
public static String accessToken(PrivateKey key, UUID userId, Duration ttl) {
Instant now = Instant.now();
return Jwts.builder()
.id(UUID.randomUUID().toString())
.subject(userId.toString())
.issuer("patbond-auth")
.claim("sid", UUID.randomUUID().toString())
.issuedAt(Date.from(now))
.expiration(Date.from(now.plus(ttl)))
.signWith(key, Jwts.SIG.RS256)
.compact();
}
private static KeyPair generate() {
try {
KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA");
generator.initialize(2048);
return generator.generateKeyPair();
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException(e);
}
}
}