fix: allow JwtDecoder bean override in test context
- Add @Primary + @ConditionalOnMissingBean so TestSecurityConfig.jwtDecoder() wins over SecurityConfig.jwtDecoder() without needing a real OIDC endpoint - Add spring.main.allow-bean-definition-overriding=true and cameleer.clickhouse.enabled=false to src/test/resources/application-test.yml so Testcontainers @ServiceConnection can supply the datasource - Disable ClickHouse in test profile (src/main/resources/application-test.yml) so the explicit ClickHouseConfig DataSource bean is not created, allowing @ServiceConnection to wire the Testcontainers Postgres datasource - Fix TenantControllerTest and LicenseControllerTest to explicitly grant ROLE_platform-admin authority via .authorities() on the test JWT, since spring-security-test does not run the custom JwtAuthenticationConverter - Fix EnvironmentService.createDefaultForTenant() to use an internal bootstrap path that skips license enforcement (chicken-and-egg: no license exists at tenant creation time yet) - Remove now-unnecessary license stub from EnvironmentServiceTest Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -6,6 +6,7 @@ import com.nimbusds.jose.proc.JWSVerificationKeySelector;
|
|||||||
import com.nimbusds.jose.proc.SecurityContext;
|
import com.nimbusds.jose.proc.SecurityContext;
|
||||||
import com.nimbusds.jwt.proc.DefaultJWTProcessor;
|
import com.nimbusds.jwt.proc.DefaultJWTProcessor;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
|
||||||
import org.springframework.context.annotation.Bean;
|
import org.springframework.context.annotation.Bean;
|
||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.context.annotation.Configuration;
|
||||||
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
|
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
|
||||||
@@ -78,6 +79,7 @@ public class SecurityConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
|
@ConditionalOnMissingBean
|
||||||
public JwtDecoder jwtDecoder(
|
public JwtDecoder jwtDecoder(
|
||||||
@Value("${spring.security.oauth2.resourceserver.jwt.jwk-set-uri}") String jwkSetUri,
|
@Value("${spring.security.oauth2.resourceserver.jwt.jwk-set-uri}") String jwkSetUri,
|
||||||
@Value("${spring.security.oauth2.resourceserver.jwt.issuer-uri:}") String issuerUri) throws Exception {
|
@Value("${spring.security.oauth2.resourceserver.jwt.issuer-uri:}") String issuerUri) throws Exception {
|
||||||
|
|||||||
@@ -49,7 +49,19 @@ public class EnvironmentService {
|
|||||||
|
|
||||||
public EnvironmentEntity createDefaultForTenant(UUID tenantId) {
|
public EnvironmentEntity createDefaultForTenant(UUID tenantId) {
|
||||||
return environmentRepository.findByTenantIdAndSlug(tenantId, "default")
|
return environmentRepository.findByTenantIdAndSlug(tenantId, "default")
|
||||||
.orElseGet(() -> create(tenantId, "default", "Default", null));
|
.orElseGet(() -> createInternal(tenantId, "default", "Default"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Creates an environment without license enforcement — used for bootstrapping (e.g., tenant provisioning). */
|
||||||
|
private EnvironmentEntity createInternal(UUID tenantId, String slug, String displayName) {
|
||||||
|
if (environmentRepository.existsByTenantIdAndSlug(tenantId, slug)) {
|
||||||
|
throw new IllegalArgumentException("Slug already exists for this tenant: " + slug);
|
||||||
|
}
|
||||||
|
var entity = new EnvironmentEntity();
|
||||||
|
entity.setTenantId(tenantId);
|
||||||
|
entity.setSlug(slug);
|
||||||
|
entity.setDisplayName(displayName);
|
||||||
|
return environmentRepository.save(entity);
|
||||||
}
|
}
|
||||||
|
|
||||||
public List<EnvironmentEntity> listByTenantId(UUID tenantId) {
|
public List<EnvironmentEntity> listByTenantId(UUID tenantId) {
|
||||||
|
|||||||
@@ -8,3 +8,7 @@ spring:
|
|||||||
resourceserver:
|
resourceserver:
|
||||||
jwt:
|
jwt:
|
||||||
issuer-uri: https://test-issuer.example.com/oidc
|
issuer-uri: https://test-issuer.example.com/oidc
|
||||||
|
|
||||||
|
cameleer:
|
||||||
|
clickhouse:
|
||||||
|
enabled: false
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package net.siegeln.cameleer.saas;
|
|||||||
|
|
||||||
import org.springframework.boot.test.context.TestConfiguration;
|
import org.springframework.boot.test.context.TestConfiguration;
|
||||||
import org.springframework.context.annotation.Bean;
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Primary;
|
||||||
import org.springframework.security.oauth2.jwt.Jwt;
|
import org.springframework.security.oauth2.jwt.Jwt;
|
||||||
import org.springframework.security.oauth2.jwt.JwtDecoder;
|
import org.springframework.security.oauth2.jwt.JwtDecoder;
|
||||||
|
|
||||||
@@ -12,6 +13,7 @@ import java.util.List;
|
|||||||
public class TestSecurityConfig {
|
public class TestSecurityConfig {
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
|
@Primary
|
||||||
public JwtDecoder jwtDecoder() {
|
public JwtDecoder jwtDecoder() {
|
||||||
return token -> Jwt.withTokenValue(token)
|
return token -> Jwt.withTokenValue(token)
|
||||||
.header("alg", "ES384")
|
.header("alg", "ES384")
|
||||||
|
|||||||
@@ -160,15 +160,9 @@ class EnvironmentServiceTest {
|
|||||||
@Test
|
@Test
|
||||||
void createDefaultForTenant_shouldCreateWithDefaultSlug() {
|
void createDefaultForTenant_shouldCreateWithDefaultSlug() {
|
||||||
var tenantId = UUID.randomUUID();
|
var tenantId = UUID.randomUUID();
|
||||||
var license = new LicenseEntity();
|
|
||||||
license.setTenantId(tenantId);
|
|
||||||
license.setTier("LOW");
|
|
||||||
|
|
||||||
when(environmentRepository.findByTenantIdAndSlug(tenantId, "default")).thenReturn(Optional.empty());
|
when(environmentRepository.findByTenantIdAndSlug(tenantId, "default")).thenReturn(Optional.empty());
|
||||||
when(environmentRepository.existsByTenantIdAndSlug(tenantId, "default")).thenReturn(false);
|
when(environmentRepository.existsByTenantIdAndSlug(tenantId, "default")).thenReturn(false);
|
||||||
when(licenseRepository.findFirstByTenantIdAndRevokedAtIsNullOrderByCreatedAtDesc(tenantId))
|
|
||||||
.thenReturn(Optional.of(license));
|
|
||||||
when(environmentRepository.countByTenantId(tenantId)).thenReturn(0L);
|
|
||||||
when(environmentRepository.save(any(EnvironmentEntity.class))).thenAnswer(inv -> inv.getArgument(0));
|
when(environmentRepository.save(any(EnvironmentEntity.class))).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
var result = environmentService.createDefaultForTenant(tenantId);
|
var result = environmentService.createDefaultForTenant(tenantId);
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import org.springframework.http.MediaType;
|
|||||||
import org.springframework.test.context.ActiveProfiles;
|
import org.springframework.test.context.ActiveProfiles;
|
||||||
import org.springframework.test.web.servlet.MockMvc;
|
import org.springframework.test.web.servlet.MockMvc;
|
||||||
|
|
||||||
|
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||||
|
|
||||||
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt;
|
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt;
|
||||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||||
@@ -36,7 +38,10 @@ class LicenseControllerTest {
|
|||||||
var request = new CreateTenantRequest("License Test Org", slug, "MID");
|
var request = new CreateTenantRequest("License Test Org", slug, "MID");
|
||||||
|
|
||||||
var result = mockMvc.perform(post("/api/tenants")
|
var result = mockMvc.perform(post("/api/tenants")
|
||||||
.with(jwt().jwt(j -> j.claim("sub", "test-user")))
|
.with(jwt().jwt(j -> j
|
||||||
|
.claim("sub", "test-user")
|
||||||
|
.claim("roles", java.util.List.of("platform-admin")))
|
||||||
|
.authorities(new SimpleGrantedAuthority("ROLE_platform-admin")))
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(objectMapper.writeValueAsString(request)))
|
.content(objectMapper.writeValueAsString(request)))
|
||||||
.andExpect(status().isCreated())
|
.andExpect(status().isCreated())
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import org.springframework.http.MediaType;
|
|||||||
import org.springframework.test.context.ActiveProfiles;
|
import org.springframework.test.context.ActiveProfiles;
|
||||||
import org.springframework.test.web.servlet.MockMvc;
|
import org.springframework.test.web.servlet.MockMvc;
|
||||||
|
|
||||||
|
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||||
|
|
||||||
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt;
|
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt;
|
||||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||||
@@ -39,7 +41,8 @@ class TenantControllerTest {
|
|||||||
.with(jwt().jwt(j -> j
|
.with(jwt().jwt(j -> j
|
||||||
.claim("sub", "test-user")
|
.claim("sub", "test-user")
|
||||||
.claim("organization_id", "test-org")
|
.claim("organization_id", "test-org")
|
||||||
.claim("roles", java.util.List.of("platform-admin"))))
|
.claim("roles", java.util.List.of("platform-admin")))
|
||||||
|
.authorities(new SimpleGrantedAuthority("ROLE_platform-admin")))
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(objectMapper.writeValueAsString(request)))
|
.content(objectMapper.writeValueAsString(request)))
|
||||||
.andExpect(status().isCreated())
|
.andExpect(status().isCreated())
|
||||||
@@ -56,7 +59,8 @@ class TenantControllerTest {
|
|||||||
mockMvc.perform(post("/api/tenants")
|
mockMvc.perform(post("/api/tenants")
|
||||||
.with(jwt().jwt(j -> j
|
.with(jwt().jwt(j -> j
|
||||||
.claim("sub", "test-user")
|
.claim("sub", "test-user")
|
||||||
.claim("roles", java.util.List.of("platform-admin"))))
|
.claim("roles", java.util.List.of("platform-admin")))
|
||||||
|
.authorities(new SimpleGrantedAuthority("ROLE_platform-admin")))
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(objectMapper.writeValueAsString(request)))
|
.content(objectMapper.writeValueAsString(request)))
|
||||||
.andExpect(status().isCreated());
|
.andExpect(status().isCreated());
|
||||||
@@ -64,7 +68,8 @@ class TenantControllerTest {
|
|||||||
mockMvc.perform(post("/api/tenants")
|
mockMvc.perform(post("/api/tenants")
|
||||||
.with(jwt().jwt(j -> j
|
.with(jwt().jwt(j -> j
|
||||||
.claim("sub", "test-user")
|
.claim("sub", "test-user")
|
||||||
.claim("roles", java.util.List.of("platform-admin"))))
|
.claim("roles", java.util.List.of("platform-admin")))
|
||||||
|
.authorities(new SimpleGrantedAuthority("ROLE_platform-admin")))
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(objectMapper.writeValueAsString(request)))
|
.content(objectMapper.writeValueAsString(request)))
|
||||||
.andExpect(status().isConflict());
|
.andExpect(status().isConflict());
|
||||||
@@ -88,7 +93,8 @@ class TenantControllerTest {
|
|||||||
var createResult = mockMvc.perform(post("/api/tenants")
|
var createResult = mockMvc.perform(post("/api/tenants")
|
||||||
.with(jwt().jwt(j -> j
|
.with(jwt().jwt(j -> j
|
||||||
.claim("sub", "test-user")
|
.claim("sub", "test-user")
|
||||||
.claim("roles", java.util.List.of("platform-admin"))))
|
.claim("roles", java.util.List.of("platform-admin")))
|
||||||
|
.authorities(new SimpleGrantedAuthority("ROLE_platform-admin")))
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(objectMapper.writeValueAsString(request)))
|
.content(objectMapper.writeValueAsString(request)))
|
||||||
.andExpect(status().isCreated())
|
.andExpect(status().isCreated())
|
||||||
|
|||||||
7
src/test/resources/application-test.yml
Normal file
7
src/test/resources/application-test.yml
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
spring:
|
||||||
|
main:
|
||||||
|
allow-bean-definition-overriding: true
|
||||||
|
|
||||||
|
cameleer:
|
||||||
|
clickhouse:
|
||||||
|
enabled: false
|
||||||
Reference in New Issue
Block a user