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
+115
View File
@@ -0,0 +1,115 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>de.platesoft</groupId>
<artifactId>plate-auth-parent</artifactId>
<version>${revision}</version>
<relativePath>../pom.xml</relativePath>
</parent>
<artifactId>plate-auth-integration-tests</artifactId>
<name>plate-auth-integration-tests</name>
<description>Greenfield consumer integration tests for plate-auth-starter</description>
<dependencies>
<!-- The starter under test -->
<dependency>
<groupId>de.platesoft</groupId>
<artifactId>plate-auth-starter</artifactId>
<version>${revision}</version>
</dependency>
<!-- Spring Boot for running the test app -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webflux</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-test</artifactId>
<scope>test</scope>
</dependency>
<!-- Testcontainers Postgres -->
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>postgresql</artifactId>
<version>${testcontainers.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<version>${testcontainers.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<!-- Run *IT.java integration tests during verify phase -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-failsafe-plugin</artifactId>
<executions>
<execution>
<goals>
<goal>integration-test</goal>
<goal>verify</goal>
</goals>
</execution>
</executions>
</plugin>
<!-- Only run tests, no packaging needed -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-jar-plugin</artifactId>
<executions>
<execution>
<id>default-jar</id>
<phase>none</phase>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-install-plugin</artifactId>
<configuration>
<skip>true</skip>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-deploy-plugin</artifactId>
<configuration>
<skip>true</skip>
</configuration>
</plugin>
</plugins>
</build>
</project>
@@ -0,0 +1,53 @@
package de.platesoft.auth.it;
import de.platesoft.auth.service.ExchangeService;
import de.platesoft.auth.service.JwtService;
import de.platesoft.auth.service.MembershipService;
import de.platesoft.auth.spi.OrgValidator;
import de.platesoft.auth.spi.defaults.PermissiveOrgValidator;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.ApplicationContext;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import static org.junit.jupiter.api.Assertions.*;
/**
* T-IT02: Auto-config wires all required beans with sensible defaults.
*/
@SpringBootTest(classes = TestConsumerApplication.class)
class AuthBootstrapIT {
@DynamicPropertySource
static void properties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", () -> "jdbc:h2:mem:bootstrap_test;DB_CLOSE_DELAY=-1");
registry.add("spring.datasource.driver-class-name", () -> "org.h2.Driver");
registry.add("plate.auth.jwt.secret", () -> "integration-test-jwt-secret-at-least-32-chars!");
registry.add("plate.auth.exchange.secret", () -> "integration-test-exchange-secret-min32chars!");
registry.add("spring.jpa.hibernate.ddl-auto", () -> "create-drop");
registry.add("spring.jpa.properties.org.hibernate.envers.autoRegisterListeners", () -> "false");
registry.add("spring.jpa.properties.hibernate.envers.autoRegisterListeners", () -> "false");
registry.add("spring.flyway.enabled", () -> "false");
registry.add("plate.auth.flyway.enabled", () -> "false");
}
@Autowired
private ApplicationContext ctx;
@Test
void requiredBeansArePresent() {
assertNotNull(ctx.getBean(JwtService.class));
assertNotNull(ctx.getBean(ExchangeService.class));
assertNotNull(ctx.getBean(MembershipService.class));
assertNotNull(ctx.getBean(SecurityFilterChain.class));
}
@Test
void defaultOrgValidatorIsPermissive() {
OrgValidator validator = ctx.getBean(OrgValidator.class);
assertInstanceOf(PermissiveOrgValidator.class, validator);
}
}
@@ -0,0 +1,119 @@
package de.platesoft.auth.it;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.server.LocalServerPort;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.HexFormat;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.*;
/**
* T-IT03: Full exchange flow — sign envelope, POST to /api/auth/exchange, get tokens back.
*/
@SpringBootTest(classes = TestConsumerApplication.class, webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class ExchangeFlowIT {
private static final String EXCHANGE_SECRET = "integration-test-exchange-secret-min32chars!";
@DynamicPropertySource
static void properties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", () -> "jdbc:h2:mem:exchange_test;DB_CLOSE_DELAY=-1");
registry.add("spring.datasource.driver-class-name", () -> "org.h2.Driver");
registry.add("plate.auth.jwt.secret", () -> "integration-test-jwt-secret-at-least-32-chars!");
registry.add("plate.auth.exchange.secret", () -> EXCHANGE_SECRET);
registry.add("plate.auth.cors.allowed-origins[0]", () -> "http://localhost");
registry.add("spring.jpa.hibernate.ddl-auto", () -> "create-drop");
registry.add("spring.jpa.properties.org.hibernate.envers.autoRegisterListeners", () -> "false");
registry.add("spring.jpa.properties.hibernate.envers.autoRegisterListeners", () -> "false");
registry.add("spring.flyway.enabled", () -> "false");
registry.add("plate.auth.flyway.enabled", () -> "false");
}
@LocalServerPort
private int port;
private final HttpClient http = HttpClient.newHttpClient();
@Test
void exchangeFlow_validEnvelope_returnsTokens() throws Exception {
String nonce = UUID.randomUUID().toString();
long iat = Instant.now().getEpochSecond();
String body = """
{"provider":"google","providerSubject":"google-sub-123","email":"test@example.com","name":"Test User","nonce":"%s","iat":%d}
""".formatted(nonce, iat).trim();
String signature = hmacSha256Hex(body, EXCHANGE_SECRET);
HttpResponse<String> resp = http.send(HttpRequest.newBuilder()
.uri(URI.create("http://localhost:" + port + "/api/auth/exchange"))
.header("Content-Type", "application/json")
.header("X-Exchange-Signature", signature)
.POST(HttpRequest.BodyPublishers.ofString(body))
.build(), HttpResponse.BodyHandlers.ofString());
assertEquals(200, resp.statusCode());
assertTrue(resp.body().contains("accessToken"));
assertTrue(resp.body().contains("refreshToken"));
}
@Test
void exchangeFlow_tamperedSignature_returns401() throws Exception {
String nonce = UUID.randomUUID().toString();
long iat = Instant.now().getEpochSecond();
String body = """
{"provider":"google","providerSubject":"google-sub-456","email":"tamper@example.com","name":"Tamper","nonce":"%s","iat":%d}
""".formatted(nonce, iat).trim();
HttpResponse<String> resp = http.send(HttpRequest.newBuilder()
.uri(URI.create("http://localhost:" + port + "/api/auth/exchange"))
.header("Content-Type", "application/json")
.header("X-Exchange-Signature", "deadbeef0000000000000000000000000000000000000000000000000000000")
.POST(HttpRequest.BodyPublishers.ofString(body))
.build(), HttpResponse.BodyHandlers.ofString());
assertEquals(401, resp.statusCode());
}
@Test
void exchangeFlow_replayedNonce_rejectsSecondCall() throws Exception {
String nonce = UUID.randomUUID().toString();
long iat = Instant.now().getEpochSecond();
String body = """
{"provider":"google","providerSubject":"google-sub-789","email":"replay@example.com","name":"Replay","nonce":"%s","iat":%d}
""".formatted(nonce, iat).trim();
String signature = hmacSha256Hex(body, EXCHANGE_SECRET);
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("http://localhost:" + port + "/api/auth/exchange"))
.header("Content-Type", "application/json")
.header("X-Exchange-Signature", signature)
.POST(HttpRequest.BodyPublishers.ofString(body))
.build();
// First call succeeds
HttpResponse<String> first = http.send(request, HttpResponse.BodyHandlers.ofString());
assertEquals(200, first.statusCode());
// Second call with same nonce fails
HttpResponse<String> second = http.send(request, HttpResponse.BodyHandlers.ofString());
assertEquals(401, second.statusCode());
}
private String hmacSha256Hex(String data, String secret) throws Exception {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
return HexFormat.of().formatHex(mac.doFinal(data.getBytes(StandardCharsets.UTF_8)));
}
}
@@ -0,0 +1,80 @@
package de.platesoft.auth.it;
import org.flywaydb.core.Flyway;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import javax.sql.DataSource;
import java.sql.ResultSet;
import java.sql.Statement;
import static org.junit.jupiter.api.Assertions.*;
/**
* T-IT01: Flyway migrations V1..V6 apply cleanly on a fresh Postgres.
* Requires Docker — skipped locally if TESTCONTAINERS_DOCKER_SOCKET_OVERRIDE is not set.
* Will run in CI where Docker is available.
*/
@Testcontainers
@SpringBootTest(classes = TestConsumerApplication.class)
@EnabledIfEnvironmentVariable(named = "CI", matches = ".*", disabledReason = "Requires Docker (CI only)")
class PlateAuthFlywayMigrationIT {
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine");
@DynamicPropertySource
static void properties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", postgres::getJdbcUrl);
registry.add("spring.datasource.username", postgres::getUsername);
registry.add("spring.datasource.password", postgres::getPassword);
registry.add("plate.auth.jwt.secret", () -> "integration-test-jwt-secret-at-least-32-chars!");
registry.add("plate.auth.exchange.secret", () -> "integration-test-exchange-secret-min32chars!");
registry.add("spring.jpa.hibernate.ddl-auto", () -> "validate");
registry.add("spring.flyway.enabled", () -> "false");
}
@Autowired
private DataSource dataSource;
@Autowired
private Flyway plateAuthFlyway;
@Test
void migrationAppliesV1ThroughV6() throws Exception {
try (var conn = dataSource.getConnection();
Statement stmt = conn.createStatement()) {
ResultSet rs = stmt.executeQuery(
"SELECT COUNT(*) FROM flyway_schema_history_auth WHERE success = true");
rs.next();
int count = rs.getInt(1);
assertEquals(6, count, "Expected 6 successful migrations in flyway_schema_history_auth");
ResultSet tables = stmt.executeQuery(
"SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' " +
"AND table_name IN ('users', 'user_identities', 'memberships', 'invitations', 'access_requests', 'login_events', 'refresh_tokens') " +
"ORDER BY table_name");
int tableCount = 0;
while (tables.next()) tableCount++;
assertEquals(7, tableCount, "Expected 7 auth tables created by migrations");
}
}
@Test
void microsoftTenantIdIndexExists() throws Exception {
try (var conn = dataSource.getConnection();
Statement stmt = conn.createStatement()) {
ResultSet rs = stmt.executeQuery(
"SELECT indexname FROM pg_indexes WHERE indexname = 'idx_user_identities_microsoft_tenant_id'");
assertTrue(rs.next(), "Microsoft tenant_id index from V5 should exist");
}
}
}
@@ -0,0 +1,15 @@
package de.platesoft.auth.it;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
/**
* Minimal Spring Boot application that consumes plate-auth-starter.
* Used only for integration tests — simulates a greenfield consumer.
*/
@SpringBootApplication
public class TestConsumerApplication {
public static void main(String[] args) {
SpringApplication.run(TestConsumerApplication.class, args);
}
}
+6
View File
@@ -0,0 +1,6 @@
# Test consumer application config — minimal for integration tests
spring:
application:
name: plate-auth-it
jpa:
open-in-view: false