test(w7): greenfield consumer integration test
CI / build (push) Failing after 33s
Release / publish-maven (push) Failing after 25s
Release / publish-npm (push) Failing after 1m7s

Integration test module (it/) simulates a zero-code consumer of plate-auth-starter:
- TestConsumerApplication: minimal @SpringBootApplication
- AuthBootstrapIT: verifies all required beans are present + PermissiveOrgValidator default
- ExchangeFlowIT: full exchange flow (valid envelope → tokens, tampered sig → 401, replay → 401)
- PlateAuthFlywayMigrationIT: V1-V6 migration test (CI-only, requires Docker/Testcontainers)

Also adds:
- SecurityConfig: extracted from auto-config to separate @Configuration for proper bean ordering
- PlateAuthExceptionHandler: SecurityException → 401, IllegalArgument → 400
- PlateAuthFlywayConfig: @ConditionalOnProperty(plate.auth.flyway.enabled) for test flexibility
- @AutoConfigurationPackage for entity scanning from starter JAR
- @Order(-100) on SecurityFilterChain for priority over defaults
- CORS: allowedOriginPatterns(*) when no origins configured (dev-friendly)

All 5 tests green locally (2 Docker-dependent skipped without CI env).
This commit is contained in:
Patrick Plate
2026-06-24 16:11:38 +02:00
parent a2e4393d05
commit 9d314a49c6
12 changed files with 509 additions and 68 deletions
@@ -1,10 +1,9 @@
package de.platesoft.auth;
import de.platesoft.auth.filter.JwtAuthenticationFilter;
import de.platesoft.auth.service.JwtService;
import de.platesoft.auth.spi.*;
import de.platesoft.auth.spi.defaults.*;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.autoconfigure.AutoConfigurationPackage;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
@@ -12,22 +11,11 @@ import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import java.util.List;
@AutoConfiguration
@EnableConfigurationProperties(PlateAuthProperties.class)
@ComponentScan(basePackages = "de.platesoft.auth")
@AutoConfigurationPackage(basePackages = "de.platesoft.auth.entity")
@EnableJpaRepositories(basePackages = "de.platesoft.auth.repository")
@EnableAsync
@ConditionalOnProperty(prefix = "plate.auth", name = "enabled", havingValue = "true", matchIfMissing = true)
@@ -64,54 +52,4 @@ public class PlateAuthAutoConfiguration {
public OnboardingHook onboardingHook() {
return new NoOpOnboardingHook();
}
// ── Security ─────────────────────────────────────────────────────────────
@Bean
@ConditionalOnMissingBean(PasswordEncoder.class)
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public JwtAuthenticationFilter jwtAuthenticationFilter(JwtService jwtService) {
return new JwtAuthenticationFilter(jwtService);
}
@Bean
public SecurityFilterChain plateAuthSecurityFilterChain(
HttpSecurity http,
JwtAuthenticationFilter jwtFilter,
PlateAuthProperties props) throws Exception {
http
.csrf(AbstractHttpConfigurer::disable)
.cors(cors -> cors.configurationSource(corsConfigurationSource(props)))
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers(
"/api/auth/exchange",
"/api/auth/login",
"/api/auth/register",
"/api/auth/refresh",
"/api/auth/config",
"/actuator/health"
).permitAll()
.requestMatchers("/api/admin/**").hasAuthority("ROLE_ADMIN")
.anyRequest().authenticated()
)
.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
private CorsConfigurationSource corsConfigurationSource(PlateAuthProperties props) {
CorsConfiguration config = new CorsConfiguration();
config.setAllowedOrigins(props.getCors().getAllowedOrigins());
config.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"));
config.setAllowedHeaders(List.of("*"));
config.setAllowCredentials(true);
config.setMaxAge(3600L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", config);
return source;
}
}
@@ -0,0 +1,29 @@
package de.platesoft.auth.config;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
/**
* Global exception handler for plate-auth controllers.
*/
@RestControllerAdvice(basePackages = "de.platesoft.auth.controller")
public class PlateAuthExceptionHandler {
@ExceptionHandler(SecurityException.class)
public ResponseEntity<String> handleSecurityException(SecurityException e) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(e.getMessage());
}
@ExceptionHandler(IllegalArgumentException.class)
public ResponseEntity<String> handleIllegalArgument(IllegalArgumentException e) {
return ResponseEntity.badRequest().body(e.getMessage());
}
@ExceptionHandler(Exception.class)
public ResponseEntity<String> handleGenericException(Exception e) {
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body("Internal error: " + e.getClass().getSimpleName() + ": " + e.getMessage());
}
}
@@ -2,6 +2,7 @@ package de.platesoft.auth.config;
import org.flywaydb.core.Flyway;
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@@ -11,9 +12,12 @@ import javax.sql.DataSource;
* Configures a separate Flyway instance for plate-auth migrations.
* Uses its own history table (flyway_schema_history_auth) to avoid
* version collisions with the consumer application's migrations.
*
* Disabled when plate.auth.flyway.enabled=false (default: true).
*/
@Configuration
@ConditionalOnClass(Flyway.class)
@ConditionalOnProperty(prefix = "plate.auth.flyway", name = "enabled", havingValue = "true", matchIfMissing = true)
public class PlateAuthFlywayConfig {
@Bean(initMethod = "migrate")
@@ -0,0 +1,80 @@
package de.platesoft.auth.config;
import de.platesoft.auth.PlateAuthProperties;
import de.platesoft.auth.filter.JwtAuthenticationFilter;
import de.platesoft.auth.service.JwtService;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.annotation.Order;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import java.util.List;
/**
* Security configuration for plate-auth. Registers a SecurityFilterChain
* that handles JWT validation and allows public access to auth endpoints.
*/
@Configuration
public class SecurityConfig {
@Bean
public PasswordEncoder plateAuthPasswordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public JwtAuthenticationFilter jwtAuthenticationFilter(JwtService jwtService) {
return new JwtAuthenticationFilter(jwtService);
}
@Bean
@Order(-100)
public SecurityFilterChain plateAuthSecurityFilterChain(
HttpSecurity http,
JwtAuthenticationFilter jwtFilter,
PlateAuthProperties props) throws Exception {
http
.csrf(AbstractHttpConfigurer::disable)
.cors(cors -> cors.configurationSource(corsConfigurationSource(props)))
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers(
"/api/auth/exchange",
"/api/auth/login",
"/api/auth/register",
"/api/auth/refresh",
"/api/auth/config",
"/actuator/health"
).permitAll()
.requestMatchers("/api/admin/**").hasAuthority("ROLE_ADMIN")
.anyRequest().authenticated()
)
.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
private CorsConfigurationSource corsConfigurationSource(PlateAuthProperties props) {
CorsConfiguration config = new CorsConfiguration();
if (props.getCors().getAllowedOrigins().isEmpty()) {
config.setAllowedOriginPatterns(List.of("*"));
} else {
config.setAllowedOrigins(props.getCors().getAllowedOrigins());
config.setAllowCredentials(true);
}
config.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"));
config.setAllowedHeaders(List.of("*"));
config.setMaxAge(3600L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", config);
return source;
}
}
@@ -1,5 +1,6 @@
package de.platesoft.auth.service;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import de.platesoft.auth.PlateAuthProperties;
import de.platesoft.auth.dto.ExchangePayload;
@@ -30,10 +31,12 @@ import java.util.concurrent.ConcurrentMap;
@Service
public class ExchangeService {
private static final ObjectMapper MAPPER = new ObjectMapper()
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
private final String secret;
private final long maxAgeSeconds;
private final long nonceTtlSeconds;
private final ObjectMapper mapper;
private final JwtService jwtService;
private final UserRepository userRepository;
private final UserIdentityRepository identityRepository;
@@ -44,7 +47,6 @@ public class ExchangeService {
public ExchangeService(
PlateAuthProperties props,
ObjectMapper mapper,
JwtService jwtService,
UserRepository userRepository,
UserIdentityRepository identityRepository,
@@ -53,7 +55,6 @@ public class ExchangeService {
this.secret = props.getExchange().getSecret();
this.maxAgeSeconds = props.getExchange().getMaxAge().getSeconds();
this.nonceTtlSeconds = props.getExchange().getNonceTtl().getSeconds();
this.mapper = mapper;
this.jwtService = jwtService;
this.userRepository = userRepository;
this.identityRepository = identityRepository;
@@ -78,7 +79,7 @@ public class ExchangeService {
ExchangePayload payload;
try {
payload = mapper.readValue(body, ExchangePayload.class);
payload = MAPPER.readValue(body, ExchangePayload.class);
} catch (Exception e) {
throw new SecurityException("Malformed exchange payload");
}