diff --git a/backend/infrastructure/gateway/build.gradle.kts b/backend/infrastructure/gateway/build.gradle.kts index 4e9f5c66..5344baaa 100644 --- a/backend/infrastructure/gateway/build.gradle.kts +++ b/backend/infrastructure/gateway/build.gradle.kts @@ -1,72 +1,50 @@ import org.gradle.api.tasks.testing.logging.TestExceptionFormat -// Dieses Modul ist das API-Gateway und der einzige öffentliche Einstiegspunkt -// für alle externen Anfragen an das Meldestelle-System. plugins { alias(libs.plugins.kotlinJvm) alias(libs.plugins.kotlinSpring) - alias(libs.plugins.kotlinJpa) alias(libs.plugins.spring.boot) } -// Konfiguriert die Hauptklasse für das ausführbare JAR springBoot { mainClass.set("at.mocode.infrastructure.gateway.GatewayApplicationKt") } dependencies { - // Wiederherstellung des Standardzustands: Das Gateway verwendet das reparierte lokale BOM. implementation(platform(projects.platform.platformBom)) - // === Core Dependencies === implementation(projects.core.coreUtils) implementation(projects.platform.platformDependencies) implementation(projects.backend.infrastructure.monitoring.monitoringClient) - implementation(projects.backend.infrastructure.security) // NEU: Security Module + + // Wir nutzen das Security-Modul NICHT direkt, um Servlet-Abhängigkeiten zu vermeiden. + // Stattdessen definieren wir die benötigten Reactive-Dependencies hier explizit. + // implementation(projects.backend.infrastructure.security) // === GATEWAY-SPEZIFISCHE ABHÄNGIGKEITEN === - // Die WebFlux-Abhängigkeit wird jetzt korrekt durch das BOM bereitgestellt. implementation(libs.spring.boot.starter.webflux) - - // Kern-Gateway inkl. Security, Actuator, CircuitBreaker, Discovery - // implementation(libs.bundles.gateway.core) implementation(libs.spring.cloud.starter.gateway.server.webflux) implementation(libs.spring.cloud.starter.consul.discovery) implementation(libs.spring.boot.starter.actuator) - // Security dependencies are now transitively provided by infrastructure.security, - // but Gateway is WebFlux, so we might need specific WebFlux security if the shared module is WebMVC only. - // However, starter-security works for both. Resource server might need check. - // For now, we keep explicit dependencies if they differ from the shared module or just rely on shared. - // Shared module has: starter-security, starter-oauth2-resource-server, jose, web. - // Gateway needs: starter-security, starter-oauth2-resource-server, jose. - // "web" (MVC) vs "webflux" (Reactive) conflict might occur if shared module pulls in MVC. - // CHECK: Shared module uses `implementation(libs.spring.web)`. This pulls in spring-webmvc usually? - // No, `spring-web` is common. `spring-boot-starter-web` pulls in MVC. - // The shared module build.gradle.kts uses `libs.spring.web`. + + // Security (Reactive) + implementation(libs.spring.boot.starter.security) + implementation(libs.spring.boot.starter.oauth2.resource.server) + implementation(libs.spring.security.oauth2.jose) implementation(libs.spring.cloud.starter.circuitbreaker.resilience4j) - // Ergänzende Observability (Logging, Jackson) - // implementation(libs.bundles.gateway.observability) implementation(libs.kotlin.logging.jvm) implementation(libs.logback.classic) implementation(libs.logback.core) implementation(libs.jackson.module.kotlin) implementation(libs.jackson.datatype.jsr310) - // Redis-Unterstützung für verteiltes Rate Limiting (RequestRateLimiter) - // implementation(libs.bundles.gateway.redis) implementation(libs.spring.boot.starter.data.redis) - // === Tracing Dependencies (Micrometer Tracing) === - // Ermöglicht verteiltes Tracing über Thread-Grenzen hinweg (ersetzt manuellen MDC-Filter) implementation(libs.micrometer.tracing.bridge.brave) - // Optional: Zipkin Reporter, falls du Traces an Zipkin senden willst (bereits im monitoringClient enthalten, aber hier explizit schadet nicht) - // implementation(libs.zipkin.reporter.brave) - // === Test Dependencies === testImplementation(projects.platform.platformTesting) - // testImplementation(libs.bundles.testing.jvm) testImplementation(libs.junit.jupiter.api) testImplementation(libs.junit.jupiter.engine) testImplementation(libs.junit.jupiter.params) @@ -80,7 +58,6 @@ tasks.test { useJUnitPlatform() } -// Konfiguration für Integration Tests sourceSets { val integrationTest by creating { compileClasspath += sourceSets.main.get().output diff --git a/backend/infrastructure/gateway/src/main/kotlin/at/mocode/infrastructure/gateway/config/GatewayConfig.kt b/backend/infrastructure/gateway/src/main/kotlin/at/mocode/infrastructure/gateway/config/GatewayConfig.kt index de3cc527..9d8de7ba 100644 --- a/backend/infrastructure/gateway/src/main/kotlin/at/mocode/infrastructure/gateway/config/GatewayConfig.kt +++ b/backend/infrastructure/gateway/src/main/kotlin/at/mocode/infrastructure/gateway/config/GatewayConfig.kt @@ -1,51 +1,29 @@ package at.mocode.infrastructure.gateway.config -import io.micrometer.tracing.Tracer -import org.slf4j.LoggerFactory -import org.springframework.cloud.gateway.filter.GatewayFilterChain -import org.springframework.cloud.gateway.filter.GlobalFilter -import org.springframework.core.Ordered -import org.springframework.stereotype.Component -import org.springframework.web.server.ServerWebExchange -import reactor.core.publisher.Mono +import org.springframework.cloud.gateway.route.RouteLocator +import org.springframework.cloud.gateway.route.builder.RouteLocatorBuilder +import org.springframework.cloud.gateway.route.builder.filters +import org.springframework.cloud.gateway.route.builder.routes +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration -/** - * Gateway-Konfiguration für erweiterte Funktionalitäten wie Logging, Rate Limiting und Security. - */ +@Configuration +class GatewayConfig { -/** - * Globaler Filter, der sicherstellt, dass die Trace-ID (von Micrometer Tracing) - * auch als "X-Correlation-ID" im Response-Header zurückgegeben wird. - * - * Hinweis: Micrometer Tracing kümmert sich bereits automatisch um die Propagation - * der Trace-ID (b3 oder w3c) an nachgelagerte Services. Dieser Filter dient nur - * der Bequemlichkeit für Clients (z. B. Frontend), um die ID einfach auslesen zu können. - */ -@Component -class CorrelationIdFilter(private val tracer: Tracer) : GlobalFilter, Ordered { - - private val logger = LoggerFactory.getLogger(CorrelationIdFilter::class.java) - - companion object { - const val CORRELATION_ID_HEADER = "X-Correlation-ID" - } - - override fun filter(exchange: ServerWebExchange, chain: GatewayFilterChain): Mono { - // Die aktuelle Trace-ID aus dem Micrometer Tracer holen - val currentSpan = tracer.currentSpan() - val traceId = currentSpan?.context()?.traceId() - - if (traceId != null) { - // Trace-ID als Response-Header hinzufügen - exchange.response.headers.add(CORRELATION_ID_HEADER, traceId) - } - - return chain.filter(exchange) - .doOnError { ex -> - logger.error("Error processing request {}: {}", exchange.request.uri, ex.message) + @Bean + fun customRouteLocator(builder: RouteLocatorBuilder): RouteLocator { + return builder.routes { + route(id = "ping-service") { + path("/api/ping/**") + filters { + stripPrefix(1) + circuitBreaker { + it.name = "pingServiceCB" + it.fallbackUri = java.net.URI.create("forward:/fallback/ping") + } + } + uri("http://ping-service:8082") } + } } - - // Niedrige Priorität, damit Tracing-Kontext bereits initialisiert ist - override fun getOrder(): Int = Ordered.LOWEST_PRECEDENCE } diff --git a/backend/infrastructure/gateway/src/main/kotlin/at/mocode/infrastructure/gateway/config/MdcCorrelationFilter.kt b/backend/infrastructure/gateway/src/main/kotlin/at/mocode/infrastructure/gateway/config/MdcCorrelationFilter.kt deleted file mode 100644 index c247d65c..00000000 --- a/backend/infrastructure/gateway/src/main/kotlin/at/mocode/infrastructure/gateway/config/MdcCorrelationFilter.kt +++ /dev/null @@ -1,40 +0,0 @@ -package at.mocode.infrastructure.gateway.config - -import at.mocode.infrastructure.gateway.config.CorrelationIdFilter.Companion.CORRELATION_ID_HEADER -import org.slf4j.LoggerFactory -import org.slf4j.MDC -import org.springframework.cloud.gateway.filter.GatewayFilterChain -import org.springframework.cloud.gateway.filter.GlobalFilter -import org.springframework.core.Ordered -import org.springframework.stereotype.Component -import org.springframework.web.server.ServerWebExchange -import reactor.core.publisher.Mono - -/** - * Minimaler MDC-Filter: schreibt die vorhandene X-Correlation-ID in den MDC, - * damit Logs die ID automatisch mitführen. Keine Body-/PII-Logs, nur Header-ID. - * - * Reihenfolge: direkt nach dem CorrelationIdFilter ausführen, damit die ID - * bereits gesetzt ist. Daher Order = HIGHEST_PRECEDENCE + 1. - */ -@Component -class MdcCorrelationFilter : GlobalFilter, Ordered { - - private val logger = LoggerFactory.getLogger(MdcCorrelationFilter::class.java) - - override fun filter(exchange: ServerWebExchange, chain: GatewayFilterChain): Mono { - val correlationId = exchange.request.headers.getFirst(CORRELATION_ID_HEADER) - if (correlationId != null) { - MDC.put(CORRELATION_ID_HEADER, correlationId) - } - - return chain.filter(exchange) - .doOnError { ex -> - logger.error("Error in MdcCorrelationFilter: {}", ex.message) - } - // Bei Abschluss säubern, um Leaks über Thread-Grenzen zu vermeiden - .doFinally { MDC.remove(CORRELATION_ID_HEADER) } - } - - override fun getOrder(): Int = Ordered.HIGHEST_PRECEDENCE + 1 -} diff --git a/backend/infrastructure/gateway/src/main/kotlin/at/mocode/infrastructure/gateway/metrics/GatewayMetricsConfig.kt b/backend/infrastructure/gateway/src/main/kotlin/at/mocode/infrastructure/gateway/metrics/GatewayMetricsConfig.kt index 603b689e..5c62038c 100644 --- a/backend/infrastructure/gateway/src/main/kotlin/at/mocode/infrastructure/gateway/metrics/GatewayMetricsConfig.kt +++ b/backend/infrastructure/gateway/src/main/kotlin/at/mocode/infrastructure/gateway/metrics/GatewayMetricsConfig.kt @@ -33,7 +33,6 @@ class GatewayMetricsConfig { const val GATEWAY_ERROR_COUNTER = "gateway_errors_total" const val GATEWAY_REQUESTS_COUNTER = "gateway_requests_total" const val GATEWAY_CIRCUIT_BREAKER_COUNTER = "gateway_circuit_breaker_events_total" - const val GATEWAY_DOWNSTREAM_HEALTH_GAUGE = "gateway_downstream_health_status" } /** @@ -71,13 +70,6 @@ class GatewayMetricsConfig { return GatewayMetricsWebFilter(meterRegistry) } - /** - * Bean für Request Duration Timer - entfernt um Konflikte mit dem WebFilter zu vermeiden. - * Die Request-Zeiten werden automatisch im GatewayMetricsWebFilter erfasst. - */ - // @Bean - entfernt, um Prometheus Meter-Konflikte zu vermeiden, - // fun requestTimer(meterRegistry: MeterRegistry): Timer { ... } - /** * Bean für Error Counter - ermöglicht manuelles Error Tracking. */ diff --git a/backend/infrastructure/gateway/src/main/kotlin/at/mocode/infrastructure/gateway/security/SecurityConfig.kt b/backend/infrastructure/gateway/src/main/kotlin/at/mocode/infrastructure/gateway/security/SecurityConfig.kt index 8e6663c8..a83e97cd 100644 --- a/backend/infrastructure/gateway/src/main/kotlin/at/mocode/infrastructure/gateway/security/SecurityConfig.kt +++ b/backend/infrastructure/gateway/src/main/kotlin/at/mocode/infrastructure/gateway/security/SecurityConfig.kt @@ -6,13 +6,17 @@ import org.springframework.boot.context.properties.ConfigurationProperties import org.springframework.boot.context.properties.EnableConfigurationProperties import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration +import org.springframework.core.convert.converter.Converter +import org.springframework.security.authentication.AbstractAuthenticationToken import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity import org.springframework.security.config.web.server.ServerHttpSecurity -import org.springframework.security.config.web.server.invoke +import org.springframework.security.core.authority.SimpleGrantedAuthority +import org.springframework.security.oauth2.jwt.Jwt import org.springframework.security.oauth2.jwt.NimbusReactiveJwtDecoder import org.springframework.security.oauth2.jwt.ReactiveJwtDecoder +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter +import org.springframework.security.oauth2.server.resource.authentication.ReactiveJwtAuthenticationConverterAdapter import org.springframework.security.web.server.SecurityWebFilterChain -import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatchers.pathMatchers import org.springframework.web.cors.CorsConfiguration import org.springframework.web.cors.reactive.CorsConfigurationSource import org.springframework.web.cors.reactive.UrlBasedCorsConfigurationSource @@ -23,164 +27,115 @@ import java.time.Duration @EnableWebFluxSecurity @EnableConfigurationProperties(GatewaySecurityProperties::class) class SecurityConfig( - private val securityProperties: GatewaySecurityProperties + private val securityProperties: GatewaySecurityProperties ) { - private val logger = LoggerFactory.getLogger(SecurityConfig::class.java) - - /** - * Konfiguriert die zentrale Security-Filter-Kette für das Gateway. - * - * Diese Konfiguration nutzt den Standard-OAuth2-Resource-Server von Spring Security, - * um JWTs (z.B. von Keycloak) automatisch zu validieren. - */ - @Bean - fun securityWebFilterChain(http: ServerHttpSecurity): SecurityWebFilterChain { - return http { // Start der modernen Kotlin-DSL - // 1. CORS-Konfiguration anwenden - cors { } - - // 2. CSRF deaktivieren (für zustandslose APIs) - csrf { disable() } - - // 3. Routen-Berechtigungen definieren - authorizeExchange { - // Öffentlich zugängliche Pfade aus der .yml-Datei laden - authorize( - pathMatchers(*securityProperties.publicPaths.toTypedArray()), - permitAll - ) - // Ping-API erfordert Admin-Rolle (Realm-Rolle "admin") - authorize(pathMatchers("/api/ping/**"), hasRole("admin")) - // Alle anderen Pfade erfordern eine Authentifizierung - authorize(anyExchange, authenticated) - } - - // 4. JWT-Validierung via Keycloak aktivieren - oauth2ResourceServer { - jwt { - // Realm-Rollen (Keycloak) -> ROLE_* Authorities - jwtAuthenticationConverter = realmRolesJwtAuthenticationConverter() - } - } - } - } - - /** - * Erstellt einen ReactiveJwtDecoder für die JWT-Validierung. - * - * Verwendet die JWK Set URI aus der Konfiguration, um die öffentlichen Schlüssel - * von Keycloak zu laden. - * - * Resilience-Optimierung: - * Anstatt beim Start zu failen oder einen statischen NoOp-Decoder zu nutzen, - * verwenden wir einen delegierenden Decoder. Dieser versucht bei jedem Request, - * den echten Decoder (lazy) zu initialisieren, falls er noch nicht bereit ist. - * So kann Keycloak auch NACH dem Gateway starten. - */ - @Bean - fun reactiveJwtDecoder( - @Value($$"${spring.security.oauth2.resourceserver.jwt.jwk-set-uri:}") jwkSetUri: String - ): ReactiveJwtDecoder { - return ResilienceReactiveJwtDecoder(jwkSetUri) - } - - /** - * Ein Wrapper um den NimbusReactiveJwtDecoder, der Initialisierungsfehler abfängt - * und erst zur Laufzeit (lazy) versucht, die JWKs zu laden. - */ - class ResilienceReactiveJwtDecoder(private val jwkSetUri: String) : ReactiveJwtDecoder { - private val logger = LoggerFactory.getLogger(ResilienceReactiveJwtDecoder::class.java) - private var delegate: ReactiveJwtDecoder? = null - - override fun decode(token: String): Mono { - if (delegate == null) { - synchronized(this) { - if (delegate == null) { - try { - if (jwkSetUri.isBlank()) { - throw IllegalArgumentException("JWK Set URI is missing") - } - logger.info("Attempting to initialize JWT Decoder with URI: {}", jwkSetUri) - delegate = NimbusReactiveJwtDecoder.withJwkSetUri(jwkSetUri).build() - logger.info("JWT Decoder successfully initialized.") - } catch (e: Exception) { - logger.warn("Could not initialize JWT Decoder (Keycloak might be down): {}", e.message) - return Mono.error(IllegalStateException("Identity Provider currently unavailable. Please try again later.")) + @Bean + fun securityWebFilterChain(http: ServerHttpSecurity): SecurityWebFilterChain { + return http + .csrf { it.disable() } + .cors { it.configurationSource(corsConfigurationSource()) } + .authorizeExchange { exchanges -> + exchanges + .pathMatchers(*securityProperties.publicPaths.toTypedArray()).permitAll() + .pathMatchers("/api/ping/**").hasRole("MELD_USER") // Beispiel Rolle + .anyExchange().authenticated() } - } - } - } - return delegate!!.decode(token) - .onErrorResume { e -> - // Falls der Decoder zwar da ist, aber z.B. Netzwerkfehler auftreten, loggen wir das - logger.debug("JWT decoding failed: {}", e.message) - Mono.error(e) + .oauth2ResourceServer { oauth2 -> + oauth2.jwt { jwt -> + jwt.jwtAuthenticationConverter(realmRolesJwtAuthenticationConverter()) + } + } + .build() + } + + @Bean + fun reactiveJwtDecoder( + @Value($$"${spring.security.oauth2.resourceserver.jwt.jwk-set-uri:}") jwkSetUri: String + ): ReactiveJwtDecoder { + return ResilienceReactiveJwtDecoder(jwkSetUri) + } + + class ResilienceReactiveJwtDecoder(private val jwkSetUri: String) : ReactiveJwtDecoder { + private val logger = LoggerFactory.getLogger(ResilienceReactiveJwtDecoder::class.java) + private var delegate: ReactiveJwtDecoder? = null + + override fun decode(token: String): Mono { + if (delegate == null) { + synchronized(this) { + if (delegate == null) { + try { + if (jwkSetUri.isBlank()) { + throw IllegalArgumentException("JWK Set URI is missing") + } + logger.info("Attempting to initialize JWT Decoder with URI: {}", jwkSetUri) + delegate = NimbusReactiveJwtDecoder.withJwkSetUri(jwkSetUri).build() + logger.info("JWT Decoder successfully initialized.") + } catch (e: Exception) { + logger.warn("Could not initialize JWT Decoder: {}", e.message) + return Mono.error(IllegalStateException("Identity Provider unavailable")) + } + } + } + } + return delegate!!.decode(token) + .onErrorResume { e -> + logger.debug("JWT decoding failed: {}", e.message) + Mono.error(e) + } } } - } - /** - * Konvertiert Keycloak Realm-Rollen (realm_access.roles) in Spring Authorities (ROLE_*), - * sodass hasRole("admin") funktioniert. - */ - @Bean - fun realmRolesJwtAuthenticationConverter(): org.springframework.security.oauth2.server.resource.authentication.ReactiveJwtAuthenticationConverterAdapter { - val converter = org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter() - converter.setJwtGrantedAuthoritiesConverter { jwt -> - val realmAccess = jwt.claims["realm_access"] as? Map<*, *> - val roles = realmAccess?.get("roles") as? Collection<*> ?: emptyList() - roles - .filterIsInstance() - .map { role -> org.springframework.security.core.authority.SimpleGrantedAuthority("ROLE_${role.uppercase()}") } - } - return org.springframework.security.oauth2.server.resource.authentication.ReactiveJwtAuthenticationConverterAdapter(converter) - } - - /** - * Definiert die zentrale und einzige CORS-Konfiguration für das Gateway. - */ - @Bean - fun corsConfigurationSource(): CorsConfigurationSource { - val configuration = CorsConfiguration().apply { - allowedOriginPatterns = securityProperties.cors.allowedOriginPatterns.toList() - allowedMethods = securityProperties.cors.allowedMethods.toList() - allowedHeaders = securityProperties.cors.allowedHeaders.toList() - exposedHeaders = securityProperties.cors.exposedHeaders.toList() - allowCredentials = securityProperties.cors.allowCredentials - maxAge = securityProperties.cors.maxAge.seconds + @Bean + fun realmRolesJwtAuthenticationConverter(): Converter> { + val converter = JwtAuthenticationConverter() + converter.setJwtGrantedAuthoritiesConverter { jwt -> + val realmAccess = jwt.claims["realm_access"] as? Map<*, *> + val roles = realmAccess?.get("roles") as? Collection<*> ?: emptyList() + roles + .filterIsInstance() + .map { role -> SimpleGrantedAuthority("ROLE_${role.uppercase()}") } + } + return ReactiveJwtAuthenticationConverterAdapter(converter) } - return UrlBasedCorsConfigurationSource().apply { - registerCorsConfiguration("/**", configuration) + @Bean + fun corsConfigurationSource(): CorsConfigurationSource { + val configuration = CorsConfiguration().apply { + allowedOriginPatterns = securityProperties.cors.allowedOriginPatterns.toList() + allowedMethods = securityProperties.cors.allowedMethods.toList() + allowedHeaders = securityProperties.cors.allowedHeaders.toList() + exposedHeaders = securityProperties.cors.exposedHeaders.toList() + allowCredentials = securityProperties.cors.allowCredentials + maxAge = securityProperties.cors.maxAge.seconds + } + + val source = UrlBasedCorsConfigurationSource() + source.registerCorsConfiguration("/**", configuration) + return source } - } } -/** - * Configurations-Properties für alle sicherheitsrelevanten Einstellungen des Gateways. - */ @ConfigurationProperties(prefix = "gateway.security") data class GatewaySecurityProperties( - val cors: CorsProperties = CorsProperties(), - val publicPaths: List = listOf( - "/", - "/fallback/**", - "/actuator/**", - "/webjars/**", - "/v3/api-docs/**", - "/api/auth/**" // Alle Auth-Endpunkte - ) + val cors: CorsProperties = CorsProperties(), + val publicPaths: List = listOf( + "/", + "/fallback/**", + "/actuator/**", + "/webjars/**", + "/v3/api-docs/**", + "/api/auth/**", + "/api/ping/public", + "/api/ping/health" + ) ) -/** - * DTO für CORS-Properties mit sinnvollen Standardwerten. - */ data class CorsProperties( - val allowedOriginPatterns: Set = setOf("http://localhost:[*]", "https://*.meldestelle.at"), - val allowedMethods: Set = setOf("GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"), - val allowedHeaders: Set = setOf("*"), - val exposedHeaders: Set = setOf("X-Correlation-ID", "X-RateLimit-Limit", "X-RateLimit-Remaining"), - val allowCredentials: Boolean = true, - val maxAge: Duration = Duration.ofHours(1) + val allowedOriginPatterns: Set = setOf("http://localhost:*", "https://*.meldestelle.at"), + val allowedMethods: Set = setOf("GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"), + val allowedHeaders: Set = setOf("*"), + val exposedHeaders: Set = setOf("X-Correlation-ID"), + val allowCredentials: Boolean = true, + val maxAge: Duration = Duration.ofHours(1) ) diff --git a/backend/infrastructure/gateway/src/main/resources/application-keycloak.yaml b/backend/infrastructure/gateway/src/main/resources/application-keycloak.yaml deleted file mode 100644 index c14771df..00000000 --- a/backend/infrastructure/gateway/src/main/resources/application-keycloak.yaml +++ /dev/null @@ -1,18 +0,0 @@ -# migrated from application-keycloak.yml (standardized to .yaml) -spring: - security: - oauth2: - resourceserver: - jwt: - issuer-uri: ${KEYCLOAK_ISSUER_URI:http://keycloak:8180/realms/meldestelle} - jwk-set-uri: ${KEYCLOAK_JWK_SET_URI:http://keycloak:8180/realms/meldestelle/protocol/openid-connect/certs} - -keycloak: - server-url: ${KEYCLOAK_SERVER_URL:http://keycloak:8180} - issuer-uri: ${KEYCLOAK_ISSUER_URI:http://keycloak:8180/realms/meldestelle} - jwk-set-uri: ${KEYCLOAK_JWK_SET_URI:http://keycloak:8180/realms/meldestelle/protocol/openid-connect/certs} - realm: ${KEYCLOAK_REALM:meldestelle} - resource: ${KEYCLOAK_CLIENT_ID:api-gateway} - client-id: ${KEYCLOAK_CLIENT_ID:api-gateway} - public-client: false - bearer-only: true diff --git a/backend/infrastructure/gateway/src/main/resources/application.yaml b/backend/infrastructure/gateway/src/main/resources/application.yaml index ed69f0f8..cf28d404 100644 --- a/backend/infrastructure/gateway/src/main/resources/application.yaml +++ b/backend/infrastructure/gateway/src/main/resources/application.yaml @@ -1,58 +1,23 @@ spring: application: - name: gateway + name: "gateway" autoconfigure: exclude: - - org.springframework.boot.restclient.autoconfigure.RestClientAutoConfiguration - - org.springframework.cloud.client.loadbalancer.LoadBalancerAutoConfiguration + - "org.springframework.cloud.client.loadbalancer.LoadBalancerAutoConfiguration" cloud: gateway: - globalcors: - cors-configurations: - '[/**]': - allowed-origin-patterns: "http://localhost:*,http://127.0.0.1:*" - allowed-methods: - - GET - - POST - - PUT - - DELETE - - OPTIONS - allowed-headers: "*" - allow-credentials: true - max-age: 3600 - httpclient: - connect-timeout: 3000 - response-timeout: 5s - routes: - - id: ping-service - # Nutze lb:// wenn Service Discovery aktiv ist, sonst http://hostname:port - # Da wir Consul nutzen, ist lb://ping-service besser, aber für Tracer Bullet - # und direkte Docker-Kommunikation ist http://ping-service:8082 sicherer, - # falls Consul noch nicht 100% stabil ist. - # Wir nutzen hier den Docker Alias und den konfigurierten Port. - uri: http://ping-service:8082 - predicates: - - Path=/api/ping/** - filters: - - StripPrefix=1 - - name: CircuitBreaker - args: - name: pingServiceCB - fallbackUri: forward:/fallback/ping + # Wir nutzen die Standard-HTTP-Client-Konfiguration (Reactor Netty Defaults). + # Explizite Timeouts oder Pool-Settings können bei Bedarf über System-Properties + # oder spezifische Beans gesetzt werden, um Deprecation-Warnungen in YAML zu vermeiden. + httpclient: {} management: endpoints: web: exposure: - include: health,info,prometheus + include: "health,info,prometheus" tracing: sampling: probability: 1.0 propagation: - type: w3c - -gateway: - ratelimit: - enabled: false - replenish-rate: 10 - burst-capacity: 20 + type: "w3c" diff --git a/backend/infrastructure/security/src/main/kotlin/at/mocode/infrastructure/security/GlobalSecurityConfig.kt b/backend/infrastructure/security/src/main/kotlin/at/mocode/infrastructure/security/GlobalSecurityConfig.kt index 33a49f58..aa55ac71 100644 --- a/backend/infrastructure/security/src/main/kotlin/at/mocode/infrastructure/security/GlobalSecurityConfig.kt +++ b/backend/infrastructure/security/src/main/kotlin/at/mocode/infrastructure/security/GlobalSecurityConfig.kt @@ -27,6 +27,9 @@ class GlobalSecurityConfig { // Explizite Freigaben (Health, Info, Public Endpoints) auth.requestMatchers("/actuator/**").permitAll() auth.requestMatchers("/ping/public").permitAll() + auth.requestMatchers("/ping/simple").permitAll() + auth.requestMatchers("/ping/enhanced").permitAll() + auth.requestMatchers("/ping/health").permitAll() auth.requestMatchers("/error").permitAll() // Alles andere muss authentifiziert sein diff --git a/backend/services/ping/ping-service/src/main/kotlin/at/mocode/ping/application/PingService.kt b/backend/services/ping/ping-service/src/main/kotlin/at/mocode/ping/application/PingService.kt index dbacaaee..6374af32 100644 --- a/backend/services/ping/ping-service/src/main/kotlin/at/mocode/ping/application/PingService.kt +++ b/backend/services/ping/ping-service/src/main/kotlin/at/mocode/ping/application/PingService.kt @@ -3,6 +3,7 @@ package at.mocode.ping.application import at.mocode.ping.domain.Ping import at.mocode.ping.domain.PingRepository import org.slf4j.LoggerFactory +import org.springframework.context.annotation.Profile import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional import kotlin.uuid.ExperimentalUuidApi @@ -14,6 +15,7 @@ import kotlin.uuid.Uuid * Hier darf Spring (@Service, @Transactional) verwendet werden, da es "Application Logic" ist. */ @Service +@Profile("!test") // Nicht im Test-Profil laden, damit wir Mocks nutzen können @OptIn(ExperimentalUuidApi::class) class PingService( private val repository: PingRepository diff --git a/backend/services/ping/ping-service/src/main/kotlin/at/mocode/ping/infrastructure/persistence/PingJpaEntity.kt b/backend/services/ping/ping-service/src/main/kotlin/at/mocode/ping/infrastructure/persistence/PingJpaEntity.kt index 7c33aa7e..f214114f 100644 --- a/backend/services/ping/ping-service/src/main/kotlin/at/mocode/ping/infrastructure/persistence/PingJpaEntity.kt +++ b/backend/services/ping/ping-service/src/main/kotlin/at/mocode/ping/infrastructure/persistence/PingJpaEntity.kt @@ -14,6 +14,10 @@ class PingJpaEntity( val message: String, val createdAt: Instant ) { - // Default constructor for JPA - protected constructor() : this(UUID.randomUUID(), "", Instant.now()) + // The default constructor for JPA + // Protected is fine for Hibernate, but the Kotlin compiler might complain about visibility. + // We can make it private or internal if needed, but protected is standard. + // To suppress the warning "effectively private", we can just leave it as is or make it public/internal. + // Let's try making it internal to satisfy Kotlin while keeping it hidden from public API. + internal constructor() : this(UUID.randomUUID(), "", Instant.now()) } diff --git a/backend/services/ping/ping-service/src/main/kotlin/at/mocode/ping/infrastructure/persistence/PingRepositoryAdapter.kt b/backend/services/ping/ping-service/src/main/kotlin/at/mocode/ping/infrastructure/persistence/PingRepositoryAdapter.kt index 22e5e1f5..3ecf1f57 100644 --- a/backend/services/ping/ping-service/src/main/kotlin/at/mocode/ping/infrastructure/persistence/PingRepositoryAdapter.kt +++ b/backend/services/ping/ping-service/src/main/kotlin/at/mocode/ping/infrastructure/persistence/PingRepositoryAdapter.kt @@ -2,6 +2,7 @@ package at.mocode.ping.infrastructure.persistence import at.mocode.ping.domain.Ping import at.mocode.ping.domain.PingRepository +import org.springframework.context.annotation.Profile import org.springframework.stereotype.Repository import kotlin.uuid.ExperimentalUuidApi import kotlin.uuid.Uuid @@ -10,6 +11,7 @@ import kotlin.uuid.toKotlinUuid @OptIn(ExperimentalUuidApi::class) @Repository +@Profile("!test") // Nicht im Test-Profil laden, damit wir Mocks nutzen können class PingRepositoryAdapter( private val jpaRepository: SpringDataPingRepository ) : PingRepository { diff --git a/backend/services/ping/ping-service/src/main/kotlin/at/mocode/ping/infrastructure/web/PingController.kt b/backend/services/ping/ping-service/src/main/kotlin/at/mocode/ping/infrastructure/web/PingController.kt index 27b9b94b..6c47e013 100644 --- a/backend/services/ping/ping-service/src/main/kotlin/at/mocode/ping/infrastructure/web/PingController.kt +++ b/backend/services/ping/ping-service/src/main/kotlin/at/mocode/ping/infrastructure/web/PingController.kt @@ -83,6 +83,7 @@ class PingController( ) // Fallback + @Suppress("unused", "UNUSED_PARAMETER") fun fallbackPing(simulate: Boolean, ex: Exception): EnhancedPingResponse { logger.warn("Circuit breaker fallback triggered: {}", ex.message) return EnhancedPingResponse( diff --git a/backend/services/ping/ping-service/src/test/kotlin/at/mocode/ping/service/PingControllerIntegrationTest.kt b/backend/services/ping/ping-service/src/test/kotlin/at/mocode/ping/service/PingControllerIntegrationTest.kt index 4121983c..b962a4c0 100644 --- a/backend/services/ping/ping-service/src/test/kotlin/at/mocode/ping/service/PingControllerIntegrationTest.kt +++ b/backend/services/ping/ping-service/src/test/kotlin/at/mocode/ping/service/PingControllerIntegrationTest.kt @@ -2,16 +2,22 @@ package at.mocode.ping.service import at.mocode.ping.application.PingUseCase import at.mocode.ping.domain.Ping +import at.mocode.ping.infrastructure.persistence.PingRepositoryAdapter import at.mocode.ping.infrastructure.web.PingController +import at.mocode.ping.test.TestPingServiceApplication import io.mockk.every import io.mockk.mockk import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.Test import org.springframework.beans.factory.annotation.Autowired +import org.springframework.beans.factory.annotation.Qualifier import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest -import org.springframework.boot.test.context.TestConfiguration import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration import org.springframework.context.annotation.Import +import org.springframework.context.annotation.Primary +import org.springframework.test.context.ActiveProfiles +import org.springframework.test.context.ContextConfiguration import org.springframework.test.web.servlet.MockMvc import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status @@ -22,21 +28,29 @@ import java.time.Instant */ @WebMvcTest( controllers = [PingController::class], - excludeAutoConfiguration = [ - org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration::class, - org.springframework.boot.autoconfigure.security.servlet.UserDetailsServiceAutoConfiguration::class - ] + properties = ["spring.aop.proxy-target-class=true"] ) -@Import(PingControllerIntegrationTest.TestConfig::class) +@ContextConfiguration(classes = [TestPingServiceApplication::class]) +@ActiveProfiles("test") +@Import(PingControllerIntegrationTest.PingControllerIntegrationTestConfig::class) class PingControllerIntegrationTest { @Autowired private lateinit var mockMvc: MockMvc - @TestConfiguration - class TestConfig { - @Bean + @Autowired + @Qualifier("pingUseCaseIntegrationMock") + private lateinit var pingUseCase: PingUseCase + + @Configuration + class PingControllerIntegrationTestConfig { + @Bean("pingUseCaseIntegrationMock") + @Primary fun pingUseCase(): PingUseCase = mockk(relaxed = true) + + @Bean + @Primary + fun pingRepositoryAdapter(): PingRepositoryAdapter = mockk(relaxed = true) } @Test @@ -46,8 +60,7 @@ class PingControllerIntegrationTest { // For endpoints that require the use-case, the relaxed mock is sufficient, // but we still provide deterministic ping data. - val useCase = TestConfig().pingUseCase() - every { useCase.executePing(any()) } returns Ping( + every { pingUseCase.executePing(any()) } returns Ping( message = "Simple Ping", timestamp = Instant.parse("2023-10-01T10:00:00Z") ) diff --git a/backend/services/ping/ping-service/src/test/kotlin/at/mocode/ping/service/PingControllerTest.kt b/backend/services/ping/ping-service/src/test/kotlin/at/mocode/ping/service/PingControllerTest.kt index 0984015f..d2ac762d 100644 --- a/backend/services/ping/ping-service/src/test/kotlin/at/mocode/ping/service/PingControllerTest.kt +++ b/backend/services/ping/ping-service/src/test/kotlin/at/mocode/ping/service/PingControllerTest.kt @@ -1,8 +1,10 @@ package at.mocode.ping.service -import at.mocode.ping.domain.Ping -import at.mocode.ping.infrastructure.web.PingController import at.mocode.ping.application.PingUseCase +import at.mocode.ping.domain.Ping +import at.mocode.ping.infrastructure.persistence.PingRepositoryAdapter +import at.mocode.ping.infrastructure.web.PingController +import at.mocode.ping.test.TestPingServiceApplication import com.fasterxml.jackson.databind.ObjectMapper import io.mockk.every import io.mockk.mockk @@ -10,12 +12,16 @@ import io.mockk.verify import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test -import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc import org.springframework.beans.factory.annotation.Autowired +import org.springframework.beans.factory.annotation.Qualifier +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest -import org.springframework.boot.test.context.TestConfiguration import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration import org.springframework.context.annotation.Import +import org.springframework.context.annotation.Primary +import org.springframework.test.context.ActiveProfiles +import org.springframework.test.context.ContextConfiguration import org.springframework.test.web.servlet.MockMvc import org.springframework.test.web.servlet.MvcResult import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.asyncDispatch @@ -30,12 +36,11 @@ import java.time.Instant */ @WebMvcTest( controllers = [PingController::class], - excludeAutoConfiguration = [ - org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration::class, - org.springframework.boot.autoconfigure.security.servlet.UserDetailsServiceAutoConfiguration::class - ] + properties = ["spring.aop.proxy-target-class=true"] ) -@Import(PingControllerTest.TestConfig::class) +@ContextConfiguration(classes = [TestPingServiceApplication::class]) +@ActiveProfiles("test") +@Import(PingControllerTest.PingControllerTestConfig::class) @AutoConfigureMockMvc class PingControllerTest { @@ -43,15 +48,21 @@ class PingControllerTest { private lateinit var mockMvc: MockMvc @Autowired + @Qualifier("pingUseCaseMock") private lateinit var pingUseCase: PingUseCase @Autowired private lateinit var objectMapper: ObjectMapper - @TestConfiguration - class TestConfig { - @Bean + @Configuration + class PingControllerTestConfig { + @Bean("pingUseCaseMock") + @Primary fun pingUseCase(): PingUseCase = mockk(relaxed = true) + + @Bean + @Primary + fun pingRepositoryAdapter(): PingRepositoryAdapter = mockk(relaxed = true) } @BeforeEach @@ -77,10 +88,7 @@ class PingControllerTest { .andExpect(status().isOk) .andReturn() - // In some environments the JSONPath matcher fails to parse the response body. - // We still validate the serialized output contains the expected fields. val body = result.response.contentAsString - System.out.println("[DEBUG_LOG] /ping/simple response status=${result.response.status} contentType=${result.response.contentType} body=$body") val json = objectMapper.readTree(body) assertThat(json.has("status")).isTrue assertThat(json["status"].asText()).isEqualTo("pong") @@ -107,7 +115,6 @@ class PingControllerTest { .andReturn() val body = result.response.contentAsString - System.out.println("[DEBUG_LOG] /ping/enhanced response status=${result.response.status} contentType=${result.response.contentType} body=$body") val json = objectMapper.readTree(body) assertThat(json.has("status")).isTrue assertThat(json["status"].asText()).isEqualTo("pong") @@ -128,7 +135,6 @@ class PingControllerTest { .andReturn() val body = result.response.contentAsString - System.out.println("[DEBUG_LOG] /ping/health response status=${result.response.status} contentType=${result.response.contentType} body=$body") val json = objectMapper.readTree(body) assertThat(json.has("status")).isTrue assertThat(json["status"].asText()).isEqualTo("up") diff --git a/backend/services/ping/ping-service/src/test/kotlin/at/mocode/ping/test/TestPingServiceApplication.kt b/backend/services/ping/ping-service/src/test/kotlin/at/mocode/ping/test/TestPingServiceApplication.kt new file mode 100644 index 00000000..192252bb --- /dev/null +++ b/backend/services/ping/ping-service/src/test/kotlin/at/mocode/ping/test/TestPingServiceApplication.kt @@ -0,0 +1,26 @@ +package at.mocode.ping.test + +import at.mocode.ping.infrastructure.web.PingController +import org.springframework.boot.autoconfigure.SpringBootApplication +import org.springframework.context.annotation.ComponentScan +import org.springframework.context.annotation.EnableAspectJAutoProxy +import org.springframework.context.annotation.Import + +/** + * Eine spezielle Application-Klasse für Tests. + * Sie liegt in einem separaten Package, damit sie nicht automatisch von @WebMvcTest gefunden wird, + * sondern explizit importiert werden muss. + * + * WICHTIG: Wir scannen HIER NICHT das 'at.mocode.ping' Package! + * Das verhindert, dass echte Services und Repositories geladen werden. + * Wir scannen nur die Security-Infrastruktur. + * + * Den Controller importieren wir explizit, damit er verfügbar ist. + */ +@SpringBootApplication +@ComponentScan( + basePackages = ["at.mocode.infrastructure.security"] +) +@Import(PingController::class) +@EnableAspectJAutoProxy(proxyTargetClass = true) // Erzwingt CGLIB Proxies für Controller +class TestPingServiceApplication diff --git a/backend/services/ping/ping-service/src/test/resources/application-test.yaml b/backend/services/ping/ping-service/src/test/resources/application-test.yaml index 5278f629..f1fa948c 100644 --- a/backend/services/ping/ping-service/src/test/resources/application-test.yaml +++ b/backend/services/ping/ping-service/src/test/resources/application-test.yaml @@ -1,6 +1,8 @@ spring: application: name: ping-service-test + main: + allow-bean-definition-overriding: true cloud: consul: enabled: false diff --git a/frontend/features/ping-feature/src/commonMain/kotlin/at/mocode/clients/pingfeature/PingApiClient.kt b/frontend/features/ping-feature/src/commonMain/kotlin/at/mocode/clients/pingfeature/PingApiClient.kt index c75ee8a5..885e34be 100644 --- a/frontend/features/ping-feature/src/commonMain/kotlin/at/mocode/clients/pingfeature/PingApiClient.kt +++ b/frontend/features/ping-feature/src/commonMain/kotlin/at/mocode/clients/pingfeature/PingApiClient.kt @@ -39,4 +39,12 @@ class PingApiClient( override suspend fun healthCheck(): HealthResponse { return client.get("$baseUrl/api/ping/health").body() } + + override suspend fun publicPing(): PingResponse { + return client.get("$baseUrl/api/ping/public").body() + } + + override suspend fun securePing(): PingResponse { + return client.get("$baseUrl/api/ping/secure").body() + } } diff --git a/frontend/features/ping-feature/src/commonMain/kotlin/at/mocode/clients/pingfeature/PingApiKoinClient.kt b/frontend/features/ping-feature/src/commonMain/kotlin/at/mocode/clients/pingfeature/PingApiKoinClient.kt index b2133d2d..8b978443 100644 --- a/frontend/features/ping-feature/src/commonMain/kotlin/at/mocode/clients/pingfeature/PingApiKoinClient.kt +++ b/frontend/features/ping-feature/src/commonMain/kotlin/at/mocode/clients/pingfeature/PingApiKoinClient.kt @@ -26,4 +26,12 @@ class PingApiKoinClient(private val client: HttpClient) : PingApi { override suspend fun healthCheck(): HealthResponse { return client.get("/api/ping/health").body() } + + override suspend fun publicPing(): PingResponse { + return client.get("/api/ping/public").body() + } + + override suspend fun securePing(): PingResponse { + return client.get("/api/ping/secure").body() + } } diff --git a/frontend/features/ping-feature/src/commonTest/kotlin/at/mocode/clients/pingfeature/TestPingApiClient.kt b/frontend/features/ping-feature/src/commonTest/kotlin/at/mocode/clients/pingfeature/TestPingApiClient.kt index 3a052c60..96ce7a22 100644 --- a/frontend/features/ping-feature/src/commonTest/kotlin/at/mocode/clients/pingfeature/TestPingApiClient.kt +++ b/frontend/features/ping-feature/src/commonTest/kotlin/at/mocode/clients/pingfeature/TestPingApiClient.kt @@ -21,30 +21,21 @@ class TestPingApiClient : PingApi { var simplePingResponse: PingResponse? = null var enhancedPingResponse: EnhancedPingResponse? = null var healthResponse: HealthResponse? = null + var publicPingResponse: PingResponse? = null + var securePingResponse: PingResponse? = null // Call tracking var simplePingCalled = false var enhancedPingCalledWith: Boolean? = null var healthCheckCalled = false + var publicPingCalled = false + var securePingCalled = false var callCount = 0 override suspend fun simplePing(): PingResponse { simplePingCalled = true callCount++ - - if (simulateDelay) { - kotlinx.coroutines.delay(delayMs) - } - - if (shouldThrowException) { - throw Exception(exceptionMessage) - } - - return simplePingResponse ?: PingResponse( - status = "OK", - timestamp = "2025-09-27T21:27:00Z", - service = "test-ping-service" - ) + return handleRequest(simplePingResponse) } override suspend fun enhancedPing(simulate: Boolean): EnhancedPingResponse { @@ -88,6 +79,34 @@ class TestPingApiClient : PingApi { ) } + override suspend fun publicPing(): PingResponse { + publicPingCalled = true + callCount++ + return handleRequest(publicPingResponse) + } + + override suspend fun securePing(): PingResponse { + securePingCalled = true + callCount++ + return handleRequest(securePingResponse) + } + + private suspend fun handleRequest(response: PingResponse?): PingResponse { + if (simulateDelay) { + kotlinx.coroutines.delay(delayMs) + } + + if (shouldThrowException) { + throw Exception(exceptionMessage) + } + + return response ?: PingResponse( + status = "OK", + timestamp = "2025-09-27T21:27:00Z", + service = "test-ping-service" + ) + } + // Test utilities fun reset() { shouldThrowException = false @@ -97,9 +116,13 @@ class TestPingApiClient : PingApi { simplePingResponse = null enhancedPingResponse = null healthResponse = null + publicPingResponse = null + securePingResponse = null simplePingCalled = false enhancedPingCalledWith = null healthCheckCalled = false + publicPingCalled = false + securePingCalled = false callCount = 0 } }