社区服务 :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 <noreply@anthropic.com>
This commit is contained in:
+20
@@ -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);
|
||||
}
|
||||
}
|
||||
+37
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+21
@@ -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);
|
||||
}
|
||||
}
|
||||
+35
@@ -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> bearerAuthFilter(
|
||||
JwtVerifier jwtVerifier, ObjectMapper objectMapper) {
|
||||
FilterRegistrationBean<BearerAuthFilter> registration = new FilterRegistrationBean<>(
|
||||
new BearerAuthFilter(jwtVerifier, objectMapper));
|
||||
registration.addUrlPatterns("/api/v1/*");
|
||||
registration.setOrder(20);
|
||||
return registration;
|
||||
}
|
||||
}
|
||||
+36
@@ -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<Map<String, String>> 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));
|
||||
}
|
||||
}
|
||||
+77
@@ -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()));
|
||||
}
|
||||
}
|
||||
+41
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+43
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+66
@@ -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<ApiResponse<Void>> handleBusiness(BusinessException e) {
|
||||
return ResponseEntity.status(e.getHttpStatus())
|
||||
.body(ApiResponse.failure(e.getCode(), e.getMessage()));
|
||||
}
|
||||
|
||||
@ExceptionHandler(MethodArgumentNotValidException.class)
|
||||
public ResponseEntity<ApiResponse<Void>> 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<ApiResponse<Void>> handleMalformedRequest(Exception e) {
|
||||
return failure(ErrorCode.VALIDATION_ERROR, ErrorCode.VALIDATION_ERROR.getDefaultMessage());
|
||||
}
|
||||
|
||||
@ExceptionHandler(NoResourceFoundException.class)
|
||||
public ResponseEntity<ApiResponse<Void>> handleNoResource(NoResourceFoundException e) {
|
||||
return ResponseEntity.status(404).body(ApiResponse.failure(40400, "资源不存在"));
|
||||
}
|
||||
|
||||
@ExceptionHandler(Exception.class)
|
||||
public ResponseEntity<ApiResponse<Void>> handleUnexpected(Exception e) {
|
||||
log.error("Unhandled exception", e);
|
||||
return failure(ErrorCode.INTERNAL_ERROR, ErrorCode.INTERNAL_ERROR.getDefaultMessage());
|
||||
}
|
||||
|
||||
private static ResponseEntity<ApiResponse<Void>> failure(ErrorCode errorCode, String message) {
|
||||
return ResponseEntity.status(errorCode.getHttpStatus())
|
||||
.body(ApiResponse.failure(errorCode.getCode(), message));
|
||||
}
|
||||
}
|
||||
@@ -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:}
|
||||
+18
@@ -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() {
|
||||
}
|
||||
}
|
||||
+27
@@ -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"));
|
||||
}
|
||||
}
|
||||
+36
@@ -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"));
|
||||
}
|
||||
}
|
||||
+86
@@ -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));
|
||||
}
|
||||
}
|
||||
+59
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user