diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..a5dd43b --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,128 @@ +version: '3.8' + +services: + # Redis for rate limiting and caching + redis: + image: redis:7-alpine + container_name: smartdrive-redis + ports: + - "6379:6379" + volumes: + - redis_data:/data + command: redis-server --appendonly yes + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 30s + timeout: 10s + retries: 3 + + # Eureka Service Discovery + eureka-server: + image: springcloud/eureka:latest + container_name: smartdrive-eureka + ports: + - "8761:8761" + environment: + - EUREKA_CLIENT_SERVICEURL_DEFAULTZONE=http://eureka-server:8761/eureka/ + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8761/actuator/health"] + interval: 30s + timeout: 10s + retries: 3 + + # API Gateway + api-gateway: + build: . + container_name: smartdrive-gateway + ports: + - "8080:8080" + environment: + - SPRING_PROFILES_ACTIVE=docker + - REDIS_HOST=redis + - REDIS_PORT=6379 + - EUREKA_CLIENT_SERVICEURL_DEFAULTZONE=http://eureka-server:8761/eureka/ + - GATEWAY_INTERNAL_SECRET=gateway-secret-2024 + - GATEWAY_SIGNATURE_SECRET=signature-secret-2024 + depends_on: + redis: + condition: service_healthy + eureka-server: + condition: service_healthy + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8080/actuator/health"] + interval: 30s + timeout: 10s + retries: 3 + + # Auth Service (placeholder) + auth-service: + image: openjdk:17-alpine + container_name: smartdrive-auth + ports: + - "8085:8085" + environment: + - SPRING_PROFILES_ACTIVE=docker + - EUREKA_CLIENT_SERVICEURL_DEFAULTZONE=http://eureka-server:8761/eureka/ + depends_on: + eureka-server: + condition: service_healthy + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8085/actuator/health"] + interval: 30s + timeout: 10s + retries: 3 + + # User Service (placeholder) + user-service: + image: openjdk:17-alpine + container_name: smartdrive-user + ports: + - "8081:8081" + environment: + - SPRING_PROFILES_ACTIVE=docker + - EUREKA_CLIENT_SERVICEURL_DEFAULTZONE=http://eureka-server:8761/eureka/ + depends_on: + eureka-server: + condition: service_healthy + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8081/actuator/health"] + interval: 30s + timeout: 10s + retries: 3 + + # Prometheus for monitoring + prometheus: + image: prom/prometheus:latest + container_name: smartdrive-prometheus + ports: + - "9090:9090" + volumes: + - ./monitoring/prometheus.yml:/etc/prometheus/prometheus.yml + command: + - '--config.file=/etc/prometheus/prometheus.yml' + - '--storage.tsdb.path=/prometheus' + - '--web.console.libraries=/etc/prometheus/console_libraries' + - '--web.console.templates=/etc/prometheus/consoles' + - '--storage.tsdb.retention.time=200h' + - '--web.enable-lifecycle' + depends_on: + - api-gateway + + # Grafana for visualization + grafana: + image: grafana/grafana:latest + container_name: smartdrive-grafana + ports: + - "3000:3000" + environment: + - GF_SECURITY_ADMIN_PASSWORD=admin + volumes: + - grafana_data:/var/lib/grafana + - ./monitoring/grafana/dashboards:/etc/grafana/provisioning/dashboards + - ./monitoring/grafana/datasources:/etc/grafana/provisioning/datasources + depends_on: + - prometheus + +volumes: + redis_data: + grafana_data: \ No newline at end of file diff --git a/monitoring/prometheus.yml b/monitoring/prometheus.yml new file mode 100644 index 0000000..d4c8ad8 --- /dev/null +++ b/monitoring/prometheus.yml @@ -0,0 +1,47 @@ +global: + scrape_interval: 15s + evaluation_interval: 15s + +rule_files: + # - "first_rules.yml" + # - "second_rules.yml" + +scrape_configs: + # Prometheus itself + - job_name: 'prometheus' + static_configs: + - targets: ['localhost:9090'] + + # API Gateway + - job_name: 'api-gateway' + static_configs: + - targets: ['api-gateway:8080'] + metrics_path: '/actuator/prometheus' + scrape_interval: 10s + scrape_timeout: 5s + + # Auth Service + - job_name: 'auth-service' + static_configs: + - targets: ['auth-service:8085'] + metrics_path: '/actuator/prometheus' + scrape_interval: 15s + + # User Service + - job_name: 'user-service' + static_configs: + - targets: ['user-service:8081'] + metrics_path: '/actuator/prometheus' + scrape_interval: 15s + + # Redis + - job_name: 'redis' + static_configs: + - targets: ['redis:6379'] + + # Eureka Service Discovery + - job_name: 'eureka' + static_configs: + - targets: ['eureka-server:8761'] + metrics_path: '/actuator/prometheus' + scrape_interval: 30s \ No newline at end of file diff --git a/src/main/java/com/smartdrive/gateway/config/CircuitBreakerConfig.java b/src/main/java/com/smartdrive/gateway/config/CircuitBreakerConfig.java new file mode 100644 index 0000000..f0da832 --- /dev/null +++ b/src/main/java/com/smartdrive/gateway/config/CircuitBreakerConfig.java @@ -0,0 +1,118 @@ +package com.smartdrive.gateway.config; + +import io.github.resilience4j.circuitbreaker.CircuitBreakerConfig; +import io.github.resilience4j.timelimiter.TimeLimiterConfig; +import lombok.extern.slf4j.Slf4j; +import org.springframework.cloud.circuitbreaker.resilience4j.ReactiveResilience4JCircuitBreakerFactory; +import org.springframework.cloud.circuitbreaker.resilience4j.Resilience4JConfigBuilder; +import org.springframework.cloud.client.circuitbreaker.Customizer; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.time.Duration; + +/** + * Circuit Breaker configuration for API Gateway + * Provides fault tolerance and resilience patterns + */ +@Configuration +@Slf4j +public class CircuitBreakerConfig { + + @Bean + public Customizer defaultCustomizer() { + return factory -> factory.configureDefault(id -> new Resilience4JConfigBuilder(id) + .circuitBreakerConfig(CircuitBreakerConfig.custom() + .slidingWindowSize(10) + .failureRateThreshold(50) + .waitDurationInOpenState(Duration.ofSeconds(10)) + .permittedNumberOfCallsInHalfOpenState(5) + .slowCallRateThreshold(50) + .slowCallDurationThreshold(Duration.ofSeconds(2)) + .build()) + .timeLimiterConfig(TimeLimiterConfig.custom() + .timeoutDuration(Duration.ofSeconds(3)) + .build()) + .build()); + } + + @Bean + public Customizer authServiceCustomizer() { + return factory -> factory.configure(builder -> builder + .circuitBreakerConfig(CircuitBreakerConfig.custom() + .slidingWindowSize(20) + .failureRateThreshold(30) + .waitDurationInOpenState(Duration.ofSeconds(30)) + .permittedNumberOfCallsInHalfOpenState(10) + .slowCallRateThreshold(30) + .slowCallDurationThreshold(Duration.ofSeconds(1)) + .build()) + .timeLimiterConfig(TimeLimiterConfig.custom() + .timeoutDuration(Duration.ofSeconds(2)) + .build()), "auth-service-circuit-breaker"); + } + + @Bean + public Customizer userServiceCustomizer() { + return factory -> factory.configure(builder -> builder + .circuitBreakerConfig(CircuitBreakerConfig.custom() + .slidingWindowSize(15) + .failureRateThreshold(40) + .waitDurationInOpenState(Duration.ofSeconds(20)) + .permittedNumberOfCallsInHalfOpenState(8) + .slowCallRateThreshold(40) + .slowCallDurationThreshold(Duration.ofSeconds(1.5)) + .build()) + .timeLimiterConfig(TimeLimiterConfig.custom() + .timeoutDuration(Duration.ofSeconds(2.5)) + .build()), "user-service-cb"); + } + + @Bean + public Customizer fileStorageCustomizer() { + return factory -> factory.configure(builder -> builder + .circuitBreakerConfig(CircuitBreakerConfig.custom() + .slidingWindowSize(25) + .failureRateThreshold(25) + .waitDurationInOpenState(Duration.ofSeconds(60)) + .permittedNumberOfCallsInHalfOpenState(15) + .slowCallRateThreshold(20) + .slowCallDurationThreshold(Duration.ofSeconds(5)) + .build()) + .timeLimiterConfig(TimeLimiterConfig.custom() + .timeoutDuration(Duration.ofSeconds(10)) + .build()), "file-storage-circuit-breaker"); + } + + @Bean + public Customizer aiServiceCustomizer() { + return factory -> factory.configure(builder -> builder + .circuitBreakerConfig(CircuitBreakerConfig.custom() + .slidingWindowSize(30) + .failureRateThreshold(20) + .waitDurationInOpenState(Duration.ofSeconds(45)) + .permittedNumberOfCallsInHalfOpenState(20) + .slowCallRateThreshold(15) + .slowCallDurationThreshold(Duration.ofSeconds(10)) + .build()) + .timeLimiterConfig(TimeLimiterConfig.custom() + .timeoutDuration(Duration.ofSeconds(15)) + .build()), "ai-service-circuit-breaker"); + } + + @Bean + public Customizer searchServiceCustomizer() { + return factory -> factory.configure(builder -> builder + .circuitBreakerConfig(CircuitBreakerConfig.custom() + .slidingWindowSize(20) + .failureRateThreshold(35) + .waitDurationInOpenState(Duration.ofSeconds(25)) + .permittedNumberOfCallsInHalfOpenState(12) + .slowCallRateThreshold(30) + .slowCallDurationThreshold(Duration.ofSeconds(3)) + .build()) + .timeLimiterConfig(TimeLimiterConfig.custom() + .timeoutDuration(Duration.ofSeconds(5)) + .build()), "search-service-circuit-breaker"); + } +} \ No newline at end of file diff --git a/src/main/java/com/smartdrive/gateway/config/MonitoringConfig.java b/src/main/java/com/smartdrive/gateway/config/MonitoringConfig.java new file mode 100644 index 0000000..2471e67 --- /dev/null +++ b/src/main/java/com/smartdrive/gateway/config/MonitoringConfig.java @@ -0,0 +1,124 @@ +package com.smartdrive.gateway.config; + +import io.micrometer.core.aop.TimedAspect; +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.Timer; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.actuate.health.Health; +import org.springframework.boot.actuate.health.HealthIndicator; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.core.ReactiveRedisTemplate; +import reactor.core.publisher.Mono; + +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicLong; + +/** + * Monitoring configuration for API Gateway + * Provides custom metrics and health checks + */ +@Configuration +@RequiredArgsConstructor +@Slf4j +public class MonitoringConfig { + + private final MeterRegistry meterRegistry; + private final ReactiveRedisTemplate redisTemplate; + + // Custom counters for monitoring + private final ConcurrentHashMap requestCounters = new ConcurrentHashMap<>(); + private final ConcurrentHashMap errorCounters = new ConcurrentHashMap<>(); + + @Bean + public TimedAspect timedAspect(MeterRegistry registry) { + return new TimedAspect(registry); + } + + @Bean + public HealthIndicator redisHealthIndicator() { + return new HealthIndicator() { + @Override + public Health health() { + return redisTemplate.execute(connection -> connection.ping()) + .map(result -> { + if (result != null) { + return Health.up() + .withDetail("service", "redis") + .withDetail("status", "connected") + .build(); + } else { + return Health.down() + .withDetail("service", "redis") + .withDetail("status", "disconnected") + .build(); + } + }) + .onErrorReturn(Health.down() + .withDetail("service", "redis") + .withDetail("error", "connection failed") + .build()) + .block(); + } + }; + } + + @Bean + public HealthIndicator gatewayHealthIndicator() { + return new HealthIndicator() { + @Override + public Health health() { + long totalRequests = requestCounters.values().stream() + .mapToLong(AtomicLong::get) + .sum(); + + long totalErrors = errorCounters.values().stream() + .mapToLong(AtomicLong::get) + .sum(); + + double errorRate = totalRequests > 0 ? (double) totalErrors / totalRequests : 0.0; + + return Health.up() + .withDetail("service", "gateway") + .withDetail("total_requests", totalRequests) + .withDetail("total_errors", totalErrors) + .withDetail("error_rate", String.format("%.2f%%", errorRate * 100)) + .build(); + } + }; + } + + /** + * Increment request counter + */ + public void incrementRequestCounter(String service) { + requestCounters.computeIfAbsent(service, k -> new AtomicLong()).incrementAndGet(); + meterRegistry.counter("gateway.requests", "service", service).increment(); + } + + /** + * Increment error counter + */ + public void incrementErrorCounter(String service, String errorType) { + errorCounters.computeIfAbsent(service, k -> new AtomicLong()).incrementAndGet(); + meterRegistry.counter("gateway.errors", "service", service, "type", errorType).increment(); + } + + /** + * Record response time + */ + public Timer.Sample startTimer() { + return Timer.start(meterRegistry); + } + + /** + * Stop timer and record metrics + */ + public void stopTimer(Timer.Sample sample, String service, String status) { + sample.stop(Timer.builder("gateway.response_time") + .tag("service", service) + .tag("status", status) + .register(meterRegistry)); + } +} \ No newline at end of file diff --git a/src/main/java/com/smartdrive/gateway/config/SecurityConfig.java b/src/main/java/com/smartdrive/gateway/config/SecurityConfig.java index 21fe4f7..7773713 100644 --- a/src/main/java/com/smartdrive/gateway/config/SecurityConfig.java +++ b/src/main/java/com/smartdrive/gateway/config/SecurityConfig.java @@ -2,11 +2,15 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.config.Customizer; import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity; import org.springframework.security.config.web.server.ServerHttpSecurity; +import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator; +import org.springframework.security.oauth2.core.OAuth2TokenValidator; +import org.springframework.security.oauth2.jwt.*; import org.springframework.security.web.server.SecurityWebFilterChain; import org.springframework.security.web.server.csrf.CsrfToken; import org.springframework.web.server.WebFilter; @@ -25,6 +29,12 @@ @Slf4j public class SecurityConfig { + @Value("${spring.security.oauth2.resourceserver.jwt.issuer-uri}") + private String issuerUri; + + @Value("${gateway.jwt.audience:smartdrive-api}") + private String audience; + /** * Configure security filter chain for API Gateway */ @@ -39,6 +49,17 @@ public SecurityWebFilterChain springSecurityFilterChain(ServerHttpSecurity http) // Configure CORS .cors(Customizer.withDefaults()) + // Add security headers + .headers(headers -> headers + .frameOptions().disable() + .contentTypeOptions().disable() + .httpStrictTransportSecurity(hsts -> hsts + .maxAgeInSeconds(31536000) + .includeSubdomains(true) + .preload(true) + ) + ) + // Configure authorization .authorizeExchange(exchanges -> exchanges // Public endpoints (no authentication required) @@ -59,20 +80,48 @@ public SecurityWebFilterChain springSecurityFilterChain(ServerHttpSecurity http) .anyExchange().authenticated() ) - // Configure OAuth2 resource server + // Configure OAuth2 resource server with custom JWT validator .oauth2ResourceServer(oauth2 -> oauth2 - .jwt(Customizer.withDefaults()) + .jwt(jwt -> jwt + .jwtAuthenticationConverter(jwtAuthenticationConverter()) + .validator(jwtValidator()) + ) ); log.info("✅ API Gateway security filter chain configured successfully"); return http.build(); } + /** + * Custom JWT validator with issuer and audience validation + */ + @Bean + public OAuth2TokenValidator jwtValidator() { + OAuth2TokenValidator issuerValidator = new JwtIssuerValidator(issuerUri); + OAuth2TokenValidator audienceValidator = new JwtClaimValidator("aud", audience); + OAuth2TokenValidator timestampValidator = new JwtTimestampValidator(); + + return new DelegatingOAuth2TokenValidator<>( + issuerValidator, + audienceValidator, + timestampValidator + ); + } - - - - + /** + * Custom JWT authentication converter + */ + @Bean + public JwtAuthenticationConverter jwtAuthenticationConverter() { + JwtGrantedAuthoritiesConverter grantedAuthoritiesConverter = new JwtGrantedAuthoritiesConverter(); + grantedAuthoritiesConverter.setAuthoritiesClaimName("roles"); + grantedAuthoritiesConverter.setAuthorityPrefix("ROLE_"); + + JwtAuthenticationConverter jwtAuthenticationConverter = new JwtAuthenticationConverter(); + jwtAuthenticationConverter.setJwtGrantedAuthoritiesConverter(grantedAuthoritiesConverter); + + return jwtAuthenticationConverter; + } /** * CSRF token filter for logging (optional) diff --git a/src/main/java/com/smartdrive/gateway/filter/RequestValidationFilter.java b/src/main/java/com/smartdrive/gateway/filter/RequestValidationFilter.java new file mode 100644 index 0000000..6a89b21 --- /dev/null +++ b/src/main/java/com/smartdrive/gateway/filter/RequestValidationFilter.java @@ -0,0 +1,188 @@ +package com.smartdrive.gateway.filter; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.cloud.gateway.filter.GatewayFilterChain; +import org.springframework.cloud.gateway.filter.GlobalFilter; +import org.springframework.core.Ordered; +import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.server.reactive.ServerHttpRequest; +import org.springframework.http.server.reactive.ServerHttpResponse; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; +import org.springframework.web.server.ServerWebExchange; +import reactor.core.publisher.Mono; + +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.List; +import java.util.regex.Pattern; + +/** + * Request validation filter for API Gateway + * Validates incoming requests for security and compliance + */ +@Component +@RequiredArgsConstructor +@Slf4j +public class RequestValidationFilter implements GlobalFilter, Ordered { + + // Maximum request size (10MB) + private static final int MAX_REQUEST_SIZE = 10 * 1024 * 1024; + + // Allowed content types + private static final List ALLOWED_CONTENT_TYPES = Arrays.asList( + MediaType.APPLICATION_JSON_VALUE, + MediaType.APPLICATION_FORM_URLENCODED_VALUE, + MediaType.MULTIPART_FORM_DATA_VALUE, + MediaType.TEXT_PLAIN_VALUE + ); + + // SQL injection patterns + private static final Pattern SQL_INJECTION_PATTERN = Pattern.compile( + "(?i)(SELECT|INSERT|UPDATE|DELETE|DROP|CREATE|ALTER|EXEC|UNION|SCRIPT|JAVASCRIPT|VBSCRIPT|ONLOAD|ONERROR|ONCLICK)", + Pattern.CASE_INSENSITIVE + ); + + // XSS patterns + private static final Pattern XSS_PATTERN = Pattern.compile( + "(?i)( filter(ServerWebExchange exchange, GatewayFilterChain chain) { + ServerHttpRequest request = exchange.getRequest(); + String path = request.getPath().value(); + + log.debug("🔍 Validating request: {} {}", request.getMethod(), path); + + // Skip validation for health checks + if (path.startsWith("/actuator/health")) { + return chain.filter(exchange); + } + + // Validate content type + if (!isValidContentType(request)) { + return handleInvalidRequest(exchange, "Invalid content type", HttpStatus.UNSUPPORTED_MEDIA_TYPE); + } + + // Validate request size + if (!isValidRequestSize(request)) { + return handleInvalidRequest(exchange, "Request too large", HttpStatus.PAYLOAD_TOO_LARGE); + } + + // Validate headers for malicious content + if (containsMaliciousContent(request)) { + return handleInvalidRequest(exchange, "Malicious content detected", HttpStatus.BAD_REQUEST); + } + + // Validate user agent + if (!isValidUserAgent(request)) { + return handleInvalidRequest(exchange, "Invalid user agent", HttpStatus.BAD_REQUEST); + } + + log.debug("✅ Request validation passed for: {}", path); + return chain.filter(exchange); + } + + /** + * Validate content type + */ + private boolean isValidContentType(ServerHttpRequest request) { + String contentType = request.getHeaders().getFirst(HttpHeaders.CONTENT_TYPE); + if (contentType == null) { + return true; // Allow requests without content type (GET requests) + } + + return ALLOWED_CONTENT_TYPES.stream() + .anyMatch(allowed -> contentType.toLowerCase().startsWith(allowed.toLowerCase())); + } + + /** + * Validate request size + */ + private boolean isValidRequestSize(ServerHttpRequest request) { + String contentLength = request.getHeaders().getFirst(HttpHeaders.CONTENT_LENGTH); + if (contentLength == null) { + return true; + } + + try { + long size = Long.parseLong(contentLength); + return size <= MAX_REQUEST_SIZE; + } catch (NumberFormatException e) { + log.warn("⚠️ Invalid content length header: {}", contentLength); + return false; + } + } + + /** + * Check for malicious content in headers + */ + private boolean containsMaliciousContent(ServerHttpRequest request) { + return request.getHeaders().entrySet().stream() + .anyMatch(entry -> { + String headerName = entry.getKey(); + List headerValues = entry.getValue(); + + return headerValues.stream() + .anyMatch(value -> + SQL_INJECTION_PATTERN.matcher(value).find() || + XSS_PATTERN.matcher(value).find() + ); + }); + } + + /** + * Validate user agent + */ + private boolean isValidUserAgent(ServerHttpRequest request) { + String userAgent = request.getHeaders().getFirst(HttpHeaders.USER_AGENT); + if (!StringUtils.hasText(userAgent)) { + return false; + } + + // Block common malicious user agents + String lowerUserAgent = userAgent.toLowerCase(); + return !lowerUserAgent.contains("sqlmap") && + !lowerUserAgent.contains("nmap") && + !lowerUserAgent.contains("nikto") && + !lowerUserAgent.contains("wget") && + !lowerUserAgent.contains("curl") && + !lowerUserAgent.contains("python-requests"); + } + + /** + * Handle invalid request + */ + private Mono handleInvalidRequest(ServerWebExchange exchange, String message, HttpStatus status) { + ServerHttpResponse response = exchange.getResponse(); + response.setStatusCode(status); + + String errorResponse = String.format(""" + { + "error": "%s", + "message": "%s", + "timestamp": "%s" + } + """, status.getReasonPhrase(), message, java.time.Instant.now()); + + byte[] bytes = errorResponse.getBytes(StandardCharsets.UTF_8); + DataBuffer buffer = response.bufferFactory().wrap(bytes); + + response.getHeaders().setContentType(MediaType.APPLICATION_JSON); + + log.warn("❌ Request validation failed: {} - {}", exchange.getRequest().getPath(), message); + + return response.writeWith(Mono.just(buffer)); + } + + @Override + public int getOrder() { + return Ordered.HIGHEST_PRECEDENCE + 50; // Run early in the filter chain + } +} \ No newline at end of file diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 1db5fa5..68ae7dc 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -125,10 +125,17 @@ management: endpoints: web: exposure: - include: health,info,metrics,gateway + include: health,info,metrics,gateway,prometheus endpoint: health: show-details: always + metrics: + export: + prometheus: + enabled: true + tags: + application: ${spring.application.name} + environment: ${spring.profiles.active:default} logging: level: diff --git a/src/test/java/com/smartdrive/gateway/security/SecurityConfigTest.java b/src/test/java/com/smartdrive/gateway/security/SecurityConfigTest.java new file mode 100644 index 0000000..8a91019 --- /dev/null +++ b/src/test/java/com/smartdrive/gateway/security/SecurityConfigTest.java @@ -0,0 +1,72 @@ +package com.smartdrive.gateway.security; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.reactive.WebFluxTest; +import org.springframework.context.annotation.Import; +import org.springframework.http.HttpStatus; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.reactive.server.WebTestClient; + +@WebFluxTest +@Import(SecurityConfig.class) +@ActiveProfiles("test") +class SecurityConfigTest { + + @Autowired + private WebTestClient webTestClient; + + @Test + void publicEndpoints_ShouldBeAccessible() { + webTestClient.get() + .uri("/actuator/health") + .exchange() + .expectStatus().isOk(); + + webTestClient.get() + .uri("/auth/oauth2/authorize") + .exchange() + .expectStatus().isOk(); + } + + @Test + void protectedEndpoints_WithoutAuth_ShouldReturnUnauthorized() { + webTestClient.get() + .uri("/api/v1/users/profile") + .exchange() + .expectStatus().isUnauthorized(); + + webTestClient.get() + .uri("/api/v1/files/list") + .exchange() + .expectStatus().isUnauthorized(); + } + + @Test + @WithMockUser(roles = "SMARTDRIVE_USER") + void userEndpoints_WithUserRole_ShouldBeAccessible() { + webTestClient.get() + .uri("/api/v1/users/profile") + .exchange() + .expectStatus().isOk(); + } + + @Test + @WithMockUser(roles = "SMARTDRIVE_ADMIN") + void adminEndpoints_WithAdminRole_ShouldBeAccessible() { + webTestClient.get() + .uri("/auth/api/v1/admin/users") + .exchange() + .expectStatus().isOk(); + } + + @Test + @WithMockUser(roles = "SMARTDRIVE_USER") + void adminEndpoints_WithUserRole_ShouldReturnForbidden() { + webTestClient.get() + .uri("/auth/api/v1/admin/users") + .exchange() + .expectStatus().isForbidden(); + } +} \ No newline at end of file