From 3c671fcaf5c4e0eb985c99028700a7e7c4ef33bb Mon Sep 17 00:00:00 2001 From: Lixi20 Date: Tue, 8 Sep 2026 16:16:26 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=BB=BA=20patbond-community?= =?UTF-8?q?=20=E6=A8=A1=E5=9D=97=E9=AA=A8=E6=9E=B6=EF=BC=88ADR-017?= =?UTF-8?q?=EF=BC=8CT3-02=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 社区服务 :8084 挂入父 pom 与 compose(第五容器,依赖 postgres 健康 + user 先起保证 V5 已执行);/api/v1/** 自骨架起接 RS256 资源侧校验(无 token/畸形/ 错签/过期均 401+40101,user/pet 同款复制,P9 下沉待拍板);GET /health 探活 在 /api/v1 之外;.sample 配置模式;生产 classpath 无 Flyway,测试经 user jar 跑全链 V1..V5(pet 先例)。模块只读写 community schema。 Co-Authored-By: Claude Fable 5 --- docker-compose.yml | 23 +++ patbond-community/Dockerfile | 9 ++ patbond-community/pom.xml | 135 ++++++++++++++++++ .../community/CommunityApplication.java | 20 +++ .../config/CommunitySecurityProperties.java | 37 +++++ .../community/config/JacksonConfig.java | 21 +++ .../community/config/SecurityConfig.java | 35 +++++ .../controller/HealthController.java | 36 +++++ .../community/security/BearerAuthFilter.java | 77 ++++++++++ .../community/security/JwtVerifier.java | 41 ++++++ .../security/RsaPublicKeyLoader.java | 43 ++++++ .../community/web/GlobalExceptionHandler.java | 66 +++++++++ .../src/main/resources/application.yml.sample | 22 +++ .../community/CommunityApplicationTests.java | 18 +++ .../TestcontainersConfiguration.java | 27 ++++ .../controller/HealthControllerTest.java | 36 +++++ .../security/BearerAuthIntegrationTest.java | 86 +++++++++++ .../community/support/TestJwtKeys.java | 59 ++++++++ .../src/test/resources/application.yml | 6 + pom.xml | 1 + 20 files changed, 798 insertions(+) create mode 100644 patbond-community/Dockerfile create mode 100644 patbond-community/pom.xml create mode 100644 patbond-community/src/main/java/com/patbond/patbond/community/CommunityApplication.java create mode 100644 patbond-community/src/main/java/com/patbond/patbond/community/config/CommunitySecurityProperties.java create mode 100644 patbond-community/src/main/java/com/patbond/patbond/community/config/JacksonConfig.java create mode 100644 patbond-community/src/main/java/com/patbond/patbond/community/config/SecurityConfig.java create mode 100644 patbond-community/src/main/java/com/patbond/patbond/community/controller/HealthController.java create mode 100644 patbond-community/src/main/java/com/patbond/patbond/community/security/BearerAuthFilter.java create mode 100644 patbond-community/src/main/java/com/patbond/patbond/community/security/JwtVerifier.java create mode 100644 patbond-community/src/main/java/com/patbond/patbond/community/security/RsaPublicKeyLoader.java create mode 100644 patbond-community/src/main/java/com/patbond/patbond/community/web/GlobalExceptionHandler.java create mode 100644 patbond-community/src/main/resources/application.yml.sample create mode 100644 patbond-community/src/test/java/com/patbond/patbond/community/CommunityApplicationTests.java create mode 100644 patbond-community/src/test/java/com/patbond/patbond/community/TestcontainersConfiguration.java create mode 100644 patbond-community/src/test/java/com/patbond/patbond/community/controller/HealthControllerTest.java create mode 100644 patbond-community/src/test/java/com/patbond/patbond/community/security/BearerAuthIntegrationTest.java create mode 100644 patbond-community/src/test/java/com/patbond/patbond/community/support/TestJwtKeys.java create mode 100644 patbond-community/src/test/resources/application.yml diff --git a/docker-compose.yml b/docker-compose.yml index 5296c64..c3145ac 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -82,5 +82,28 @@ services: user: condition: service_started + # M3(ADR-017):社区服务(Feed/帖子/评论/互动)。骨架起 /api/v1/** 即接 + # RS256 校验(与 user/pet 同一公钥);只读写 community schema。与 user 共库; + # Flyway 迁移链(V1..V5,含 community 基线)由 user 服务统一执行,故依赖 + # user 先起,保证 community schema 已就绪。 + community: + build: ./patbond-community + environment: + SPRING_CONFIG_LOCATION: file:/config/application.yml + 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-community/src/main/resources/application.yml.sample:/config/application.yml:ro + - ./deploy/keys:/run/patbond/keys:ro + ports: + - "${PATBOND_COMMUNITY_PORT:-8084}:8084" + depends_on: + postgres: + condition: service_healthy + user: + condition: service_started + volumes: pgdata: diff --git a/patbond-community/Dockerfile b/patbond-community/Dockerfile new file mode 100644 index 0000000..8f55c3f --- /dev/null +++ b/patbond-community/Dockerfile @@ -0,0 +1,9 @@ +# Runtime image only — build the jar first: ./mvnw -pl patbond-community -am package +# Stateless by design (ADR-007): no local state, config via env / mounted files. +FROM eclipse-temurin:17-jre +RUN useradd --system --uid 10001 patbond +USER patbond +WORKDIR /app +COPY target/patbond-community-1.0.0-SNAPSHOT-exec.jar app.jar +EXPOSE 8084 +ENTRYPOINT ["java", "-jar", "/app/app.jar"] diff --git a/patbond-community/pom.xml b/patbond-community/pom.xml new file mode 100644 index 0000000..156907f --- /dev/null +++ b/patbond-community/pom.xml @@ -0,0 +1,135 @@ + + + 4.0.0 + + + com.patbond.patbond + patbond-api + 1.0.0-SNAPSHOT + ../pom.xml + + + patbond-community + jar + + patbond-community + Community feed, posts and interactions service for Patbond (ADR-017) + + + + + com.patbond.patbond + patbond-common + ${project.version} + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-validation + + + org.springframework.boot + spring-boot-starter-jdbc + + + org.postgresql + 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 + test + + + org.testcontainers + postgresql + test + + + org.testcontainers + junit-jupiter + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + + repackage + + + + exec + + + + + + + diff --git a/patbond-community/src/main/java/com/patbond/patbond/community/CommunityApplication.java b/patbond-community/src/main/java/com/patbond/patbond/community/CommunityApplication.java new file mode 100644 index 0000000..1168054 --- /dev/null +++ b/patbond-community/src/main/java/com/patbond/patbond/community/CommunityApplication.java @@ -0,0 +1,20 @@ +package com.patbond.patbond.community; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +/** + * Community feed, posts and interactions service (M3, ADR-017: the community + * domain lives in its own Maven module on :8084). First-wave skeleton: + * configuration wiring, datasource, RS256 bearer auth on /api/v1/** and a + * liveness endpoint — business endpoints follow the contract work in the + * next waves. The module only reads and writes the community schema + * (author profile lookups follow the D3-9 plan later). + */ +@SpringBootApplication +public class CommunityApplication { + + public static void main(String[] args) { + SpringApplication.run(CommunityApplication.class, args); + } +} diff --git a/patbond-community/src/main/java/com/patbond/patbond/community/config/CommunitySecurityProperties.java b/patbond-community/src/main/java/com/patbond/patbond/community/config/CommunitySecurityProperties.java new file mode 100644 index 0000000..5e5dea4 --- /dev/null +++ b/patbond-community/src/main/java/com/patbond/patbond/community/config/CommunitySecurityProperties.java @@ -0,0 +1,37 @@ +package com.patbond.patbond.community.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * Security knobs of the community service: only the RS256 public key for + * verifying access tokens issued by patbond-auth (same contract as + * patbond-user/pet's {@code patbond.jwt.public-key}). No /internal routes + * exist here yet, so no service token property. + */ +@ConfigurationProperties(prefix = "patbond") +public class CommunitySecurityProperties { + + 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-community/src/main/java/com/patbond/patbond/community/config/JacksonConfig.java b/patbond-community/src/main/java/com/patbond/patbond/community/config/JacksonConfig.java new file mode 100644 index 0000000..e49092e --- /dev/null +++ b/patbond-community/src/main/java/com/patbond/patbond/community/config/JacksonConfig.java @@ -0,0 +1,21 @@ +package com.patbond.patbond.community.config; + +import com.fasterxml.jackson.databind.DeserializationFeature; +import org.springframework.boot.autoconfigure.jackson.Jackson2ObjectMapperBuilderCustomizer; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * Integer fields must reject decimal input (development-plan 4.3) — Jackson's + * default is to silently truncate 45.5 to 45 in an integer field, which + * would corrupt values instead of rejecting them. Same setting as the other + * services so all envelopes behave identically. + */ +@Configuration +public class JacksonConfig { + + @Bean + public Jackson2ObjectMapperBuilderCustomizer rejectFloatAsInt() { + return builder -> builder.featuresToDisable(DeserializationFeature.ACCEPT_FLOAT_AS_INT); + } +} diff --git a/patbond-community/src/main/java/com/patbond/patbond/community/config/SecurityConfig.java b/patbond-community/src/main/java/com/patbond/patbond/community/config/SecurityConfig.java new file mode 100644 index 0000000..50e327a --- /dev/null +++ b/patbond-community/src/main/java/com/patbond/patbond/community/config/SecurityConfig.java @@ -0,0 +1,35 @@ +package com.patbond.patbond.community.config; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.patbond.patbond.community.security.BearerAuthFilter; +import com.patbond.patbond.community.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 and + * patbond-pet use. The /health probe stays outside /api/v1 and therefore + * unauthenticated. + */ +@Configuration +@EnableConfigurationProperties(CommunitySecurityProperties.class) +public class SecurityConfig { + + @Bean + public JwtVerifier jwtVerifier(CommunitySecurityProperties 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-community/src/main/java/com/patbond/patbond/community/controller/HealthController.java b/patbond-community/src/main/java/com/patbond/patbond/community/controller/HealthController.java new file mode 100644 index 0000000..b7ead10 --- /dev/null +++ b/patbond-community/src/main/java/com/patbond/patbond/community/controller/HealthController.java @@ -0,0 +1,36 @@ +package com.patbond.patbond.community.controller; + +import com.patbond.patbond.common.response.ApiResponse; +import org.springframework.jdbc.core.simple.JdbcClient; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.Map; + +/** + * Liveness/readiness probe for the skeleton phase: confirms the service is + * up and that its datasource can reach the shared PostgreSQL. Deliberately + * outside /api/v1 so it stays unauthenticated (same reasoning as compose's + * pg_isready: infrastructure probes carry no business data). + */ +@RestController +public class HealthController { + + private final JdbcClient jdbcClient; + + public HealthController(JdbcClient jdbcClient) { + this.jdbcClient = jdbcClient; + } + + @GetMapping("/health") + public ApiResponse> health() { + String db; + try { + jdbcClient.sql("SELECT 1").query(Integer.class).single(); + db = "up"; + } catch (Exception e) { + db = "down"; + } + return ApiResponse.success(Map.of("status", "ok", "db", db)); + } +} diff --git a/patbond-community/src/main/java/com/patbond/patbond/community/security/BearerAuthFilter.java b/patbond-community/src/main/java/com/patbond/patbond/community/security/BearerAuthFilter.java new file mode 100644 index 0000000..9df1e2d --- /dev/null +++ b/patbond-community/src/main/java/com/patbond/patbond/community/security/BearerAuthFilter.java @@ -0,0 +1,77 @@ +package com.patbond.patbond.community.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/** community 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. Third copy of the user/pet + * filter — sinking the shared pure-Java parts into patbond-common stays a + * separate decision (iteration-3/02 P9, not ratified in ADR-016~021). + */ +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-community/src/main/java/com/patbond/patbond/community/security/JwtVerifier.java b/patbond-community/src/main/java/com/patbond/patbond/community/security/JwtVerifier.java new file mode 100644 index 0000000..b8b2347 --- /dev/null +++ b/patbond-community/src/main/java/com/patbond/patbond/community/security/JwtVerifier.java @@ -0,0 +1,41 @@ +package com.patbond.patbond.community.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-community/src/main/java/com/patbond/patbond/community/security/RsaPublicKeyLoader.java b/patbond-community/src/main/java/com/patbond/patbond/community/security/RsaPublicKeyLoader.java new file mode 100644 index 0000000..33a4ba2 --- /dev/null +++ b/patbond-community/src/main/java/com/patbond/patbond/community/security/RsaPublicKeyLoader.java @@ -0,0 +1,43 @@ +package com.patbond.patbond.community.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-community/src/main/java/com/patbond/patbond/community/web/GlobalExceptionHandler.java b/patbond-community/src/main/java/com/patbond/patbond/community/web/GlobalExceptionHandler.java new file mode 100644 index 0000000..22e2f32 --- /dev/null +++ b/patbond-community/src/main/java/com/patbond/patbond/community/web/GlobalExceptionHandler.java @@ -0,0 +1,66 @@ +package com.patbond.patbond.community.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; +import org.springframework.http.converter.HttpMessageNotReadableException; +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; + +/** + * Single place that turns exceptions into the {code, message, data} envelope + * with a matching HTTP status (development-plan 6.1) — same contract as the + * user/auth/pet handlers. Unexpected exceptions are logged in full but never + * leak internals to the client. + */ +@RestControllerAdvice +public class GlobalExceptionHandler { + + private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class); + + @ExceptionHandler(BusinessException.class) + public ResponseEntity> handleBusiness(BusinessException e) { + return ResponseEntity.status(e.getHttpStatus()) + .body(ApiResponse.failure(e.getCode(), e.getMessage())); + } + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity> handleValidation(MethodArgumentNotValidException e) { + String message = e.getBindingResult().getFieldErrors().stream() + .findFirst() + .map(FieldError::getDefaultMessage) + .orElse(ErrorCode.VALIDATION_ERROR.getDefaultMessage()); + return failure(ErrorCode.VALIDATION_ERROR, message); + } + + @ExceptionHandler({HttpMessageNotReadableException.class, MethodArgumentTypeMismatchException.class, + ConstraintViolationException.class, HandlerMethodValidationException.class}) + public ResponseEntity> handleMalformedRequest(Exception e) { + return failure(ErrorCode.VALIDATION_ERROR, ErrorCode.VALIDATION_ERROR.getDefaultMessage()); + } + + @ExceptionHandler(NoResourceFoundException.class) + public ResponseEntity> handleNoResource(NoResourceFoundException e) { + return ResponseEntity.status(404).body(ApiResponse.failure(40400, "资源不存在")); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity> handleUnexpected(Exception e) { + log.error("Unhandled exception", e); + return failure(ErrorCode.INTERNAL_ERROR, ErrorCode.INTERNAL_ERROR.getDefaultMessage()); + } + + private static ResponseEntity> failure(ErrorCode errorCode, String message) { + return ResponseEntity.status(errorCode.getHttpStatus()) + .body(ApiResponse.failure(errorCode.getCode(), message)); + } +} diff --git a/patbond-community/src/main/resources/application.yml.sample b/patbond-community/src/main/resources/application.yml.sample new file mode 100644 index 0000000..70b16a9 --- /dev/null +++ b/patbond-community/src/main/resources/application.yml.sample @@ -0,0 +1,22 @@ +server: + port: ${PATBOND_COMMUNITY_PORT:8084} + +spring: + application: + name: patbond-community + datasource: + # 与 patbond-user 共库(MVP 单库多 schema);本服务只读写 community schema + # (作者公开资料按 D3-9 方案 B 走 user 的 /internal 批量接口,后续波次落地)。 + # Flyway 迁移链(V1..V5,含 community 基线)由 patbond-user 启动时统一执行, + # 本服务不携带 Flyway —— 单一 flyway_schema_history 归属不拆。 + url: ${PATBOND_DB_URL:jdbc:postgresql://127.0.0.1:5432/patbond} + username: ${PATBOND_DB_USER:patbond} + password: ${PATBOND_DB_PASSWORD:patbond} + +# /api/v1/** 业务端点自骨架起即接 RS256 校验,与 patbond-user/pet 同一约定。 +patbond: + jwt: + # RS256 公钥,用于本地校验 patbond-auth 签发的 access token。 + # 值可以是 PEM 文件路径,也可以是内联 PEM 内容(以 -----BEGIN 开头)。 + # 私钥只给 patbond-auth,绝不入库。 + public-key: ${PATBOND_JWT_PUBLIC_KEY:} diff --git a/patbond-community/src/test/java/com/patbond/patbond/community/CommunityApplicationTests.java b/patbond-community/src/test/java/com/patbond/patbond/community/CommunityApplicationTests.java new file mode 100644 index 0000000..9e632d5 --- /dev/null +++ b/patbond-community/src/test/java/com/patbond/patbond/community/CommunityApplicationTests.java @@ -0,0 +1,18 @@ +package com.patbond.patbond.community; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; + +/** + * Context smoke test: the skeleton boots against a clean postgres:18 with + * the full V1..V5 migration chain applied from the test classpath. + */ +@SpringBootTest +@Import(TestcontainersConfiguration.class) +class CommunityApplicationTests { + + @Test + void contextLoads() { + } +} diff --git a/patbond-community/src/test/java/com/patbond/patbond/community/TestcontainersConfiguration.java b/patbond-community/src/test/java/com/patbond/patbond/community/TestcontainersConfiguration.java new file mode 100644 index 0000000..50b0862 --- /dev/null +++ b/patbond-community/src/test/java/com/patbond/patbond/community/TestcontainersConfiguration.java @@ -0,0 +1,27 @@ +package com.patbond.patbond.community; + +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.boot.testcontainers.service.connection.ServiceConnection; +import org.springframework.context.annotation.Bean; +import org.testcontainers.containers.PostgreSQLContainer; +import org.testcontainers.utility.DockerImageName; + +/** + * Shared Testcontainers setup: a disposable postgres:18 (the production + * target version) wired into the Spring context via @ServiceConnection. + * 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..V5 chain — including the + * community schema these tests exercise — to the fresh container exactly + * as the shared database gets it in production (same mechanism as + * patbond-pet). + */ +@TestConfiguration(proxyBeanMethods = false) +public class TestcontainersConfiguration { + + @Bean + @ServiceConnection + PostgreSQLContainer postgresContainer() { + return new PostgreSQLContainer<>(DockerImageName.parse("postgres:18")); + } +} diff --git a/patbond-community/src/test/java/com/patbond/patbond/community/controller/HealthControllerTest.java b/patbond-community/src/test/java/com/patbond/patbond/community/controller/HealthControllerTest.java new file mode 100644 index 0000000..b63f8d9 --- /dev/null +++ b/patbond-community/src/test/java/com/patbond/patbond/community/controller/HealthControllerTest.java @@ -0,0 +1,36 @@ +package com.patbond.patbond.community.controller; + +import com.patbond.patbond.community.TestcontainersConfiguration; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.test.web.servlet.MockMvc; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * The liveness probe answers 200 with the standard envelope and reports the + * datasource as reachable (the Testcontainers database is up by definition). + * It sits outside /api/v1, so no token is needed. + */ +@SpringBootTest +@AutoConfigureMockMvc +@Import(TestcontainersConfiguration.class) +class HealthControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Test + void healthReportsServiceAndDatabaseUp() throws Exception { + mockMvc.perform(get("/health")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.status").value("ok")) + .andExpect(jsonPath("$.data.db").value("up")); + } +} diff --git a/patbond-community/src/test/java/com/patbond/patbond/community/security/BearerAuthIntegrationTest.java b/patbond-community/src/test/java/com/patbond/patbond/community/security/BearerAuthIntegrationTest.java new file mode 100644 index 0000000..e1abb07 --- /dev/null +++ b/patbond-community/src/test/java/com/patbond/patbond/community/security/BearerAuthIntegrationTest.java @@ -0,0 +1,86 @@ +package com.patbond.patbond.community.security; + +import com.patbond.patbond.community.TestcontainersConfiguration; +import com.patbond.patbond.community.support.TestJwtKeys; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.test.web.servlet.MockMvc; + +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.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * The BearerAuthFilter guards every /api/v1/** route from the skeleton on + * (T3-02 acceptance): missing, forged, wrong-key and expired tokens all + * answer 401 with the 40101 envelope, while a valid token passes the filter + * (and reaches the 404 of a not-yet-implemented route instead of a 401). + */ +@SpringBootTest +@AutoConfigureMockMvc +@Import(TestcontainersConfiguration.class) +class BearerAuthIntegrationTest { + + @Autowired + private MockMvc mockMvc; + + @DynamicPropertySource + static void jwtPublicKey(DynamicPropertyRegistry registry) { + registry.add("patbond.jwt.public-key", TestJwtKeys::publicPem); + } + + @Test + void missingTokenAnswers401() throws Exception { + mockMvc.perform(get("/api/v1/posts")) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value(40101)); + } + + @Test + void malformedTokenAnswers401() throws Exception { + mockMvc.perform(get("/api/v1/posts") + .header("Authorization", "Bearer not-a-jwt")) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value(40101)); + } + + @Test + void wrongKeyTokenAnswers401() throws Exception { + String forged = TestJwtKeys.accessToken(TestJwtKeys.WRONG_KEY_PAIR.getPrivate(), + UUID.randomUUID(), Duration.ofMinutes(15)); + mockMvc.perform(get("/api/v1/posts") + .header("Authorization", "Bearer " + forged)) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value(40101)); + } + + @Test + void expiredTokenAnswers401() throws Exception { + String expired = TestJwtKeys.accessToken(TestJwtKeys.KEY_PAIR.getPrivate(), + UUID.randomUUID(), Duration.ofMinutes(-5)); + mockMvc.perform(get("/api/v1/posts") + .header("Authorization", "Bearer " + expired)) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value(40101)); + } + + @Test + void validTokenPassesTheFilter() throws Exception { + // No business routes exist in the skeleton, so an authenticated + // request reaches the 404 envelope — proving the filter let it in. + String token = TestJwtKeys.accessToken(TestJwtKeys.KEY_PAIR.getPrivate(), + UUID.randomUUID(), Duration.ofMinutes(15)); + mockMvc.perform(get("/api/v1/posts") + .header("Authorization", "Bearer " + token)) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(40400)); + } +} diff --git a/patbond-community/src/test/java/com/patbond/patbond/community/support/TestJwtKeys.java b/patbond-community/src/test/java/com/patbond/patbond/community/support/TestJwtKeys.java new file mode 100644 index 0000000..5272d32 --- /dev/null +++ b/patbond-community/src/test/java/com/patbond/patbond/community/support/TestJwtKeys.java @@ -0,0 +1,59 @@ +package com.patbond.patbond.community.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); + } + } +} diff --git a/patbond-community/src/test/resources/application.yml b/patbond-community/src/test/resources/application.yml new file mode 100644 index 0000000..0482e99 --- /dev/null +++ b/patbond-community/src/test/resources/application.yml @@ -0,0 +1,6 @@ +# Test-only configuration: keeps @SpringBootTest deterministic on a clean +# checkout, where the git-ignored main application.yml does not exist. The +# datasource comes from Testcontainers (@ServiceConnection). +spring: + application: + name: patbond-community diff --git a/pom.xml b/pom.xml index a4b7675..55adb48 100644 --- a/pom.xml +++ b/pom.xml @@ -17,6 +17,7 @@ patbond-user patbond-auth patbond-pet + patbond-community