diff --git a/docker-compose.yml b/docker-compose.yml index d0116f0..5296c64 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -60,9 +60,9 @@ services: depends_on: - user - # M2(ADR-009):宠物健康档案服务。第一波为骨架(仅 /health 探活,无业务端点)。 - # 与 user 共库;Flyway 迁移链由 user 服务统一执行,故依赖 user 先起, - # 保证 pet_health schema 已就绪。 + # M2(ADR-009):宠物健康档案服务。第二波起提供 /api/v1/pets、/api/v1/breeds + # 业务端点(RS256 校验,与 user 同一公钥)。与 user 共库;Flyway 迁移链由 + # user 服务统一执行,故依赖 user 先起,保证 pet_health schema 已就绪。 pet: build: ./patbond-pet environment: @@ -70,8 +70,10 @@ services: PATBOND_DB_URL: jdbc:postgresql://postgres:5432/${PATBOND_DB_NAME:-patbond} PATBOND_DB_USER: ${PATBOND_DB_USER:-patbond} PATBOND_DB_PASSWORD: ${PATBOND_DB_PASSWORD:?先运行 deploy/init-secrets.sh 生成 .env} + PATBOND_JWT_PUBLIC_KEY: /run/patbond/keys/jwt-public.pem volumes: - ./patbond-pet/src/main/resources/application.yml.sample:/config/application.yml:ro + - ./deploy/keys:/run/patbond/keys:ro ports: - "${PATBOND_PET_PORT:-8083}:8083" depends_on: diff --git a/patbond-common/src/main/java/com/patbond/patbond/common/error/ErrorCode.java b/patbond-common/src/main/java/com/patbond/patbond/common/error/ErrorCode.java index 81830bf..316b0d1 100644 --- a/patbond-common/src/main/java/com/patbond/patbond/common/error/ErrorCode.java +++ b/patbond-common/src/main/java/com/patbond/patbond/common/error/ErrorCode.java @@ -20,6 +20,7 @@ public enum ErrorCode { USERNAME_EXISTS(40900, 409, "用户名已存在"), PHONE_EXISTS(40901, 409, "手机号已被使用"), VERSION_CONFLICT(40902, 409, "数据已被修改,请刷新后重试"), + MICROCHIP_EXISTS(40903, 409, "芯片号已被其他宠物登记"), LOGIN_LOCKED(42300, 423, "登录失败次数过多,账号已临时锁定"), INTERNAL_ERROR(50000, 500, "服务器内部错误"), DOWNSTREAM_UNAVAILABLE(50300, 503, "依赖服务暂不可用"); diff --git a/patbond-pet/pom.xml b/patbond-pet/pom.xml index f0b6069..d4c2110 100644 --- a/patbond-pet/pom.xml +++ b/patbond-pet/pom.xml @@ -17,11 +17,9 @@ patbond-pet Pet profile and health record service for Patbond (ADR-009) - + com.patbond.patbond @@ -45,11 +43,52 @@ postgresql runtime + + + io.jsonwebtoken + jjwt-api + 0.12.6 + + + io.jsonwebtoken + jjwt-impl + 0.12.6 + runtime + + + io.jsonwebtoken + jjwt-jackson + 0.12.6 + runtime + org.springframework.boot spring-boot-starter-test test + + + com.patbond.patbond + patbond-user + ${project.version} + test + + + org.flywaydb + flyway-core + test + + + org.flywaydb + flyway-database-postgresql + test + org.springframework.boot spring-boot-testcontainers diff --git a/patbond-pet/src/main/java/com/patbond/patbond/pet/access/AccessLevel.java b/patbond-pet/src/main/java/com/patbond/patbond/pet/access/AccessLevel.java new file mode 100644 index 0000000..4637bc7 --- /dev/null +++ b/patbond-pet/src/main/java/com/patbond/patbond/pet/access/AccessLevel.java @@ -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}: + * + * + */ +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; + }; + } +} diff --git a/patbond-pet/src/main/java/com/patbond/patbond/pet/access/PetAccessService.java b/patbond-pet/src/main/java/com/patbond/patbond/pet/access/PetAccessService.java new file mode 100644 index 0000000..571453c --- /dev/null +++ b/patbond-pet/src/main/java/com/patbond/patbond/pet/access/PetAccessService.java @@ -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}. + * + *

Semantics (frozen for the M2 contract, iteration-2/02 P7): + *

+ * + *

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) { + } +} diff --git a/patbond-pet/src/main/java/com/patbond/patbond/pet/access/PetRole.java b/patbond-pet/src/main/java/com/patbond/patbond/pet/access/PetRole.java new file mode 100644 index 0000000..b4f52c1 --- /dev/null +++ b/patbond-pet/src/main/java/com/patbond/patbond/pet/access/PetRole.java @@ -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(); + } +} diff --git a/patbond-pet/src/main/java/com/patbond/patbond/pet/config/PetSecurityProperties.java b/patbond-pet/src/main/java/com/patbond/patbond/pet/config/PetSecurityProperties.java new file mode 100644 index 0000000..2b9d068 --- /dev/null +++ b/patbond-pet/src/main/java/com/patbond/patbond/pet/config/PetSecurityProperties.java @@ -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; + } + } +} diff --git a/patbond-pet/src/main/java/com/patbond/patbond/pet/config/SecurityConfig.java b/patbond-pet/src/main/java/com/patbond/patbond/pet/config/SecurityConfig.java new file mode 100644 index 0000000..7950d7e --- /dev/null +++ b/patbond-pet/src/main/java/com/patbond/patbond/pet/config/SecurityConfig.java @@ -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( + JwtVerifier jwtVerifier, ObjectMapper objectMapper) { + FilterRegistrationBean registration = new FilterRegistrationBean<>( + new BearerAuthFilter(jwtVerifier, objectMapper)); + registration.addUrlPatterns("/api/v1/*"); + registration.setOrder(20); + return registration; + } +} diff --git a/patbond-pet/src/main/java/com/patbond/patbond/pet/controller/BreedController.java b/patbond-pet/src/main/java/com/patbond/patbond/pet/controller/BreedController.java new file mode 100644 index 0000000..07d0ef4 --- /dev/null +++ b/patbond-pet/src/main/java/com/patbond/patbond/pet/controller/BreedController.java @@ -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( + @RequestParam(required = false) + @Pattern(regexp = "dog|cat|other", message = "species 仅支持 dog/cat/other") + String species) { + return ApiResponse.success(breedRepository.listEnabled(species)); + } +} diff --git a/patbond-pet/src/main/java/com/patbond/patbond/pet/controller/PetController.java b/patbond-pet/src/main/java/com/patbond/patbond/pet/controller/PetController.java new file mode 100644 index 0000000..bfc8a6c --- /dev/null +++ b/patbond-pet/src/main/java/com/patbond/patbond/pet/controller/PetController.java @@ -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( + @RequestAttribute(BearerAuthFilter.USER_ID_ATTRIBUTE) UUID userId) { + return ApiResponse.success(petService.list(userId)); + } + + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + public ApiResponse create( + @RequestAttribute(BearerAuthFilter.USER_ID_ATTRIBUTE) UUID userId, + @Valid @RequestBody CreatePetRequest request) { + return ApiResponse.success(petService.create(userId, request)); + } + + @GetMapping("/{petId}") + public ApiResponse get( + @RequestAttribute(BearerAuthFilter.USER_ID_ATTRIBUTE) UUID userId, + @PathVariable UUID petId) { + return ApiResponse.success(petService.get(userId, petId)); + } + + @PatchMapping("/{petId}") + public ApiResponse update( + @RequestAttribute(BearerAuthFilter.USER_ID_ATTRIBUTE) UUID userId, + @PathVariable UUID petId, + @Valid @RequestBody UpdatePetRequest request) { + return ApiResponse.success(petService.update(userId, petId, request)); + } +} diff --git a/patbond-pet/src/main/java/com/patbond/patbond/pet/dto/BreedResponse.java b/patbond-pet/src/main/java/com/patbond/patbond/pet/dto/BreedResponse.java new file mode 100644 index 0000000..b143bcf --- /dev/null +++ b/patbond-pet/src/main/java/com/patbond/patbond/pet/dto/BreedResponse.java @@ -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) { +} diff --git a/patbond-pet/src/main/java/com/patbond/patbond/pet/dto/CreatePetRequest.java b/patbond-pet/src/main/java/com/patbond/patbond/pet/dto/CreatePetRequest.java new file mode 100644 index 0000000..c15f056 --- /dev/null +++ b/patbond-pet/src/main/java/com/patbond/patbond/pet/dto/CreatePetRequest.java @@ -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; + } +} diff --git a/patbond-pet/src/main/java/com/patbond/patbond/pet/dto/PetResponse.java b/patbond-pet/src/main/java/com/patbond/patbond/pet/dto/PetResponse.java new file mode 100644 index 0000000..cdb27ac --- /dev/null +++ b/patbond-pet/src/main/java/com/patbond/patbond/pet/dto/PetResponse.java @@ -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) { +} diff --git a/patbond-pet/src/main/java/com/patbond/patbond/pet/dto/UpdatePetRequest.java b/patbond-pet/src/main/java/com/patbond/patbond/pet/dto/UpdatePetRequest.java new file mode 100644 index 0000000..f3a326e --- /dev/null +++ b/patbond-pet/src/main/java/com/patbond/patbond/pet/dto/UpdatePetRequest.java @@ -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; + } +} diff --git a/patbond-pet/src/main/java/com/patbond/patbond/pet/repository/BreedRepository.java b/patbond-pet/src/main/java/com/patbond/patbond/pet/repository/BreedRepository.java new file mode 100644 index 0000000..220916b --- /dev/null +++ b/patbond-pet/src/main/java/com/patbond/patbond/pet/repository/BreedRepository.java @@ -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 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 findEnabledSpecies(UUID breedId) { + return jdbcClient.sql( + "SELECT species FROM pet_health.breeds WHERE id = :id AND enabled") + .param("id", breedId) + .query(String.class) + .optional(); + } +} diff --git a/patbond-pet/src/main/java/com/patbond/patbond/pet/repository/PetRepository.java b/patbond-pet/src/main/java/com/patbond/patbond/pet/repository/PetRepository.java new file mode 100644 index 0000000..6cb0519 --- /dev/null +++ b/patbond-pet/src/main/java/com/patbond/patbond/pet/repository/PetRepository.java @@ -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 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 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")); + } +} diff --git a/patbond-pet/src/main/java/com/patbond/patbond/pet/security/BearerAuthFilter.java b/patbond-pet/src/main/java/com/patbond/patbond/pet/security/BearerAuthFilter.java new file mode 100644 index 0000000..0b8970c --- /dev/null +++ b/patbond-pet/src/main/java/com/patbond/patbond/pet/security/BearerAuthFilter.java @@ -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())); + } +} diff --git a/patbond-pet/src/main/java/com/patbond/patbond/pet/security/JwtVerifier.java b/patbond-pet/src/main/java/com/patbond/patbond/pet/security/JwtVerifier.java new file mode 100644 index 0000000..bc982e3 --- /dev/null +++ b/patbond-pet/src/main/java/com/patbond/patbond/pet/security/JwtVerifier.java @@ -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); + } + } +} diff --git a/patbond-pet/src/main/java/com/patbond/patbond/pet/security/RsaPublicKeyLoader.java b/patbond-pet/src/main/java/com/patbond/patbond/pet/security/RsaPublicKeyLoader.java new file mode 100644 index 0000000..eafdd1f --- /dev/null +++ b/patbond-pet/src/main/java/com/patbond/patbond/pet/security/RsaPublicKeyLoader.java @@ -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); + } + } +} diff --git a/patbond-pet/src/main/java/com/patbond/patbond/pet/service/PetService.java b/patbond-pet/src/main/java/com/patbond/patbond/pet/service/PetService.java new file mode 100644 index 0000000..b1d1e21 --- /dev/null +++ b/patbond-pet/src/main/java/com/patbond/patbond/pet/service/PetService.java @@ -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 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; + } +} diff --git a/patbond-pet/src/main/java/com/patbond/patbond/pet/support/UuidV7.java b/patbond-pet/src/main/java/com/patbond/patbond/pet/support/UuidV7.java new file mode 100644 index 0000000..a20ca0f --- /dev/null +++ b/patbond-pet/src/main/java/com/patbond/patbond/pet/support/UuidV7.java @@ -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); + } +} diff --git a/patbond-pet/src/main/java/com/patbond/patbond/pet/web/GlobalExceptionHandler.java b/patbond-pet/src/main/java/com/patbond/patbond/pet/web/GlobalExceptionHandler.java index 864c24e..530dd99 100644 --- a/patbond-pet/src/main/java/com/patbond/patbond/pet/web/GlobalExceptionHandler.java +++ b/patbond-pet/src/main/java/com/patbond/patbond/pet/web/GlobalExceptionHandler.java @@ -3,6 +3,7 @@ package com.patbond.patbond.pet.web; import com.patbond.patbond.common.error.BusinessException; import com.patbond.patbond.common.error.ErrorCode; import com.patbond.patbond.common.response.ApiResponse; +import jakarta.validation.ConstraintViolationException; import org.slf4j.Logger; import org.slf4j.LoggerFactory; 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.annotation.ExceptionHandler; 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.servlet.resource.NoResourceFoundException; @@ -40,7 +42,8 @@ public class GlobalExceptionHandler { return failure(ErrorCode.VALIDATION_ERROR, message); } - @ExceptionHandler({HttpMessageNotReadableException.class, MethodArgumentTypeMismatchException.class}) + @ExceptionHandler({HttpMessageNotReadableException.class, MethodArgumentTypeMismatchException.class, + ConstraintViolationException.class, HandlerMethodValidationException.class}) public ResponseEntity> handleMalformedRequest(Exception e) { return failure(ErrorCode.VALIDATION_ERROR, ErrorCode.VALIDATION_ERROR.getDefaultMessage()); } diff --git a/patbond-pet/src/main/resources/application.yml.sample b/patbond-pet/src/main/resources/application.yml.sample index 9caa225..e0a8527 100644 --- a/patbond-pet/src/main/resources/application.yml.sample +++ b/patbond-pet/src/main/resources/application.yml.sample @@ -12,5 +12,10 @@ spring: username: ${PATBOND_DB_USER:patbond} password: ${PATBOND_DB_PASSWORD:patbond} -# M2 第一波骨架:暂无 /api/v1 业务端点,故尚无 JWT 公钥配置。第二波接口 -# 落地时将与 patbond-user 同一约定接入 RS256 校验(PATBOND_JWT_PUBLIC_KEY)。 +# M2 第二波:/api/v1/** 业务端点已接 RS256 校验,与 patbond-user 同一约定。 +patbond: + jwt: + # RS256 公钥,用于本地校验 patbond-auth 签发的 access token。 + # 值可以是 PEM 文件路径,也可以是内联 PEM 内容(以 -----BEGIN 开头)。 + # 私钥只给 patbond-auth,绝不入库。 + public-key: ${PATBOND_JWT_PUBLIC_KEY:} diff --git a/patbond-pet/src/test/java/com/patbond/patbond/pet/TestcontainersConfiguration.java b/patbond-pet/src/test/java/com/patbond/patbond/pet/TestcontainersConfiguration.java index c28d355..78a9188 100644 --- a/patbond-pet/src/test/java/com/patbond/patbond/pet/TestcontainersConfiguration.java +++ b/patbond-pet/src/test/java/com/patbond/patbond/pet/TestcontainersConfiguration.java @@ -9,9 +9,11 @@ import org.testcontainers.utility.DockerImageName; /** * Shared Testcontainers setup: a disposable postgres:18 (the production * target version) wired into the Spring context via @ServiceConnection. - * This module carries no Flyway (the single migration chain is owned by - * patbond-user), so tests here run against a clean database — schema-level - * assertions about pet_health live in patbond-user's migration tests. + * The production module carries no Flyway (the single migration chain is + * owned by patbond-user), but the TEST classpath adds patbond-user's jar + * 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) public class TestcontainersConfiguration { diff --git a/patbond-pet/src/test/java/com/patbond/patbond/pet/access/PetPermissionIntegrationTest.java b/patbond-pet/src/test/java/com/patbond/patbond/pet/access/PetPermissionIntegrationTest.java new file mode 100644 index 0000000..ab91edd --- /dev/null +++ b/patbond-pet/src/test/java/com/patbond/patbond/pet/access/PetPermissionIntegrationTest.java @@ -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): + * + *

+ * + *

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)); + } +} diff --git a/patbond-pet/src/test/java/com/patbond/patbond/pet/controller/PetCrudIntegrationTest.java b/patbond-pet/src/test/java/com/patbond/patbond/pet/controller/PetCrudIntegrationTest.java new file mode 100644 index 0000000..d68fc94 --- /dev/null +++ b/patbond-pet/src/test/java/com/patbond/patbond/pet/controller/PetCrudIntegrationTest.java @@ -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 owner(pet_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, "禁删"); + // 软删除不走 PATCH(ck_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); + } +} diff --git a/patbond-pet/src/test/java/com/patbond/patbond/pet/support/PetIntegrationTestSupport.java b/patbond-pet/src/test/java/com/patbond/patbond/pet/support/PetIntegrationTestSupport.java new file mode 100644 index 0000000..b9f89e3 --- /dev/null +++ b/patbond-pet/src/test/java/com/patbond/patbond/pet/support/PetIntegrationTestSupport.java @@ -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. + * + *

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(); + } +} diff --git a/patbond-pet/src/test/java/com/patbond/patbond/pet/support/TestJwtKeys.java b/patbond-pet/src/test/java/com/patbond/patbond/pet/support/TestJwtKeys.java new file mode 100644 index 0000000..d3375d8 --- /dev/null +++ b/patbond-pet/src/test/java/com/patbond/patbond/pet/support/TestJwtKeys.java @@ -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); + } + } +}