diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index cfb20bc5..3c5646bb 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -171,6 +171,28 @@ jobs: target: /home/${{ secrets.USERNAME }}/eatssu-deploy/dev rm: true + - name: prod Grafana Alloy 설정 전송 + uses: appleboy/scp-action@master + if: github.ref_name == 'main' + with: + host: ${{ secrets.HOST_PROD }} + username: ${{ secrets.USERNAME }} + key: ${{ secrets.PROD_PRIVATE_KEY }} + port: 22 + source: "monitoring/alloy-config.alloy" + target: /home/${{ secrets.USERNAME }}/eatssu-deploy/prod + + - name: dev Grafana Alloy 설정 전송 + uses: appleboy/scp-action@master + if: github.ref_name == 'develop' + with: + host: ${{ secrets.HOST_DEV }} + username: ${{ secrets.USERNAME }} + key: ${{ secrets.DEV_PRIVATE_KEY }} + port: 22 + source: "monitoring/alloy-config.alloy" + target: /home/${{ secrets.USERNAME }}/eatssu-deploy/dev + - name: prod에 배포 uses: appleboy/ssh-action@master id: deploy-prod @@ -210,7 +232,9 @@ jobs: PUBLISHED_CONTAINERS=$(sudo docker ps -q --filter publish=9000) if [ -n "$PUBLISHED_CONTAINERS" ]; then sudo docker rm -f $PUBLISHED_CONTAINERS; fi sudo docker pull ${{ secrets.DOCKER_REPO }}/eatssu-prod + sudo mkdir -p /var/log/eatssu sudo docker run -d --name eatssu-prod --restart unless-stopped -p 9000:9000 -p 127.0.0.1:9001:9001 \ + -v /var/log/eatssu:/var/log/eatssu \ --log-driver=awslogs \ --log-opt awslogs-region=ap-northeast-2 \ --log-opt awslogs-group=prod-ec2-group \ @@ -224,8 +248,17 @@ jobs: -e EATSSU_SLACK_TOKEN="${{ secrets.EATSSU_SLACK_TOKEN }}" \ -e EATSSU_PROD_DOMAIN="${{ secrets.EATSSU_PROD_DOMAIN }}" \ -e EATSSU_DEEPL_API_KEY="${{ secrets.EATSSU_DEEPL_API_KEY }}" \ + -e GRAFANA_BASE_URL="${{ secrets.EATSSU_GRAFANA_BASE_URL }}" \ + -e GRAFANA_LOKI_DATASOURCE_UID="${{ secrets.EATSSU_GRAFANA_LOKI_DATASOURCE_UID }}" \ + -e SWAGGER_USER="${{ secrets.SWAGGER_PROD_USER }}" \ + -e SWAGGER_PASSWORD="${{ secrets.SWAGGER_PROD_PASSWORD }}" \ ${{ secrets.DOCKER_REPO }}/eatssu-prod sudo docker image prune -f + ALLOY_CONFIG_SRC="$HOME/eatssu-deploy/prod/monitoring/alloy-config.alloy" + sed -e 's|__GRAFANA_TOKEN__|${{ secrets.EATSSU_GRAFANA_TOKEN }}|g' \ + -e 's|__APP_LABEL__|eatssu-prod|g' \ + "$ALLOY_CONFIG_SRC" | sudo tee /etc/alloy/config.alloy > /dev/null + sudo systemctl restart alloy - name: dev 서버에 배포 uses: appleboy/ssh-action@master @@ -265,7 +298,9 @@ jobs: PUBLISHED_CONTAINERS=$(sudo docker ps -q --filter publish=9000) if [ -n "$PUBLISHED_CONTAINERS" ]; then sudo docker rm -f $PUBLISHED_CONTAINERS; fi sudo docker pull ${{ secrets.DOCKER_REPO }}/eatssu-dev + sudo mkdir -p /var/log/eatssu sudo docker run -d --name eatssu-dev --restart unless-stopped -p 9000:9000 -p 127.0.0.1:9001:9001 \ + -v /var/log/eatssu:/var/log/eatssu \ --log-driver=awslogs \ --log-opt awslogs-region=ap-northeast-2 \ --log-opt awslogs-group=dev-ec2-group \ @@ -279,5 +314,12 @@ jobs: -e EATSSU_SLACK_TOKEN="${{ secrets.EATSSU_SLACK_TOKEN }}" \ -e EATSSU_DEV_DOMAIN="${{ secrets.EATSSU_DEV_DOMAIN }}" \ -e EATSSU_DEEPL_API_KEY="${{ secrets.EATSSU_DEEPL_API_KEY }}" \ + -e SWAGGER_USER="${{ secrets.SWAGGER_DEV_USER }}" \ + -e SWAGGER_PASSWORD="${{ secrets.SWAGGER_DEV_PASSWORD }}" \ ${{ secrets.DOCKER_REPO }}/eatssu-dev sudo docker image prune -f + ALLOY_CONFIG_SRC="$HOME/eatssu-deploy/dev/monitoring/alloy-config.alloy" + sed -e 's|__GRAFANA_TOKEN__|${{ secrets.EATSSU_GRAFANA_TOKEN }}|g' \ + -e 's|__APP_LABEL__|eatssu-dev|g' \ + "$ALLOY_CONFIG_SRC" | sudo tee /etc/alloy/config.alloy > /dev/null + sudo systemctl restart alloy diff --git a/monitoring/alloy-config.alloy b/monitoring/alloy-config.alloy new file mode 100644 index 00000000..6dab469f --- /dev/null +++ b/monitoring/alloy-config.alloy @@ -0,0 +1,40 @@ +logging { + level = "warn" +} + +prometheus.scrape "spring_app" { + targets = [{ + __address__ = "localhost:9001", + __metrics_path__ = "/actuator/prometheus", + }] + forward_to = [prometheus.remote_write.metrics_hosted_prometheus.receiver] +} + +prometheus.remote_write "metrics_hosted_prometheus" { + endpoint { + name = "hosted-prometheus" + url = "https://prometheus-prod-49-prod-ap-northeast-0.grafana.net/api/prom/push" + basic_auth { + username = "3340646" + password = "__GRAFANA_TOKEN__" + } + } +} + +loki.write "grafanacloud" { + endpoint { + url = "https://logs-prod-030.grafana.net/loki/api/v1/push" + basic_auth { + username = "1665998" + password = "__GRAFANA_TOKEN__" + } + } +} + +loki.source.file "app_log" { + targets = [{ + __path__ = "/var/log/eatssu/app.log", + application = "__APP_LABEL__", + }] + forward_to = [loki.write.grafanacloud.receiver] +} diff --git a/src/main/java/ssu/eatssu/domain/auth/infrastructure/SecurityConfig.java b/src/main/java/ssu/eatssu/domain/auth/infrastructure/SecurityConfig.java index c6c21ca9..eac42a4c 100644 --- a/src/main/java/ssu/eatssu/domain/auth/infrastructure/SecurityConfig.java +++ b/src/main/java/ssu/eatssu/domain/auth/infrastructure/SecurityConfig.java @@ -1,12 +1,20 @@ package ssu.eatssu.domain.auth.infrastructure; import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; +import org.springframework.core.annotation.Order; +import org.springframework.security.config.Customizer; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.core.userdetails.User; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.provisioning.InMemoryUserDetailsManager; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; import org.springframework.web.cors.CorsConfiguration; @@ -26,10 +34,14 @@ public class SecurityConfig { "/favicon.ico", "/error/**", "/webjars/**", "/h2-console/**" }; + private static final String[] SWAGGER_PATHS = { + "/swagger-ui.html", "/swagger-ui/**", "/v3/api-docs/**", "/swagger-resources/**" + }; + private static final String[] AUTH_WHITELIST = { "/", "/oauths/kakao", "/oauths/apple", "/menus/**", "/meals/**", "/admin/login", "/oauths/v2/kakao","/oauths/v2/apple", "/reviews", "/reviews/menus/**", "/reviews/meals/**", "/v2/reviews/statistics/**", - "/v2/reviews/menus/**", "/v2/reviews/meals/**", "/actuator/health", "/actuator/prometheus", "/error-test/**", + "/v2/reviews/menus/**", "/v2/reviews/meals/**", "/actuator/health", "/actuator/prometheus", "/good-price-stores", "/good-price-stores/**" }; @@ -47,6 +59,33 @@ public PasswordEncoder passwordEncoder() { } @Bean + @Order(1) + @Profile({"dev", "prod"}) + public SecurityFilterChain swaggerFilterChain(HttpSecurity http, + UserDetailsService swaggerUserDetailsService) throws Exception { + http + .securityMatcher(SWAGGER_PATHS) + .csrf(csrf -> csrf.disable()) + .userDetailsService(swaggerUserDetailsService) + .authorizeHttpRequests(authorize -> authorize.anyRequest().authenticated()) + .httpBasic(Customizer.withDefaults()); + return http.build(); + } + + @Bean + @Profile({"dev", "prod"}) + public UserDetailsService swaggerUserDetailsService(@Value("${swagger.username}") String username, + @Value("${swagger.password}") String password, + PasswordEncoder passwordEncoder) { + UserDetails swaggerUser = User.withUsername(username) + .password(passwordEncoder.encode(password)) + .roles("SWAGGER") + .build(); + return new InMemoryUserDetailsManager(swaggerUser); + } + + @Bean + @Order(2) protected SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http .csrf().disable() diff --git a/src/main/java/ssu/eatssu/domain/auth/security/JwtAuthenticationFilter.java b/src/main/java/ssu/eatssu/domain/auth/security/JwtAuthenticationFilter.java index e845aba9..097ced16 100644 --- a/src/main/java/ssu/eatssu/domain/auth/security/JwtAuthenticationFilter.java +++ b/src/main/java/ssu/eatssu/domain/auth/security/JwtAuthenticationFilter.java @@ -28,7 +28,7 @@ public class JwtAuthenticationFilter extends GenericFilterBean { private static final List AUTH_WHITELIST = List.of( "/", "/oauths/kakao", "/oauths/apple", "/menus/**", "/meals/**", "/admin/login", "/reviews", "/reviews/menus/**", "/reviews/meals/**", "/v2/reviews/statistics/**", - "/v2/reviews/menus/**", "/v2/reviews/meals/**", "/actuator/**", "/error-test/**", + "/v2/reviews/menus/**", "/v2/reviews/meals/**", "/actuator/**", "/swagger-ui.html", "/swagger-ui/**", "/v3/api-docs/**", "/swagger-resources/**", "/oauths/valid/token", "/admin/img/**", "/css/**", "/js/**", "/favicon.ico", "/error/**", "/webjars/**", "/h2-console/**", diff --git a/src/main/java/ssu/eatssu/domain/slack/entity/SlackMessageFormat.java b/src/main/java/ssu/eatssu/domain/slack/entity/SlackMessageFormat.java index bc8d0749..81abc5b1 100644 --- a/src/main/java/ssu/eatssu/domain/slack/entity/SlackMessageFormat.java +++ b/src/main/java/ssu/eatssu/domain/slack/entity/SlackMessageFormat.java @@ -1,5 +1,7 @@ package ssu.eatssu.domain.slack.entity; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.slf4j.MDC; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; import ssu.eatssu.domain.inquiry.entity.Inquiry; @@ -8,15 +10,31 @@ import ssu.eatssu.domain.user.entity.User; import ssu.eatssu.global.handler.response.BaseException; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; import java.text.MessageFormat; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; @Component public class SlackMessageFormat { + private static final long LOG_LINK_WINDOW_MILLIS = 5 * 60 * 1000; + private static String serverEnv; + private static String grafanaBaseUrl; + private static String lokiDatasourceUid; + private static ObjectMapper objectMapper; - private SlackMessageFormat(@Value("${server.env:unknown}") String serverEnvValue) { + private SlackMessageFormat(@Value("${server.env:unknown}") String serverEnvValue, + @Value("${grafana.base-url:}") String grafanaBaseUrlValue, + @Value("${grafana.loki-datasource-uid:}") String lokiDatasourceUidValue, + ObjectMapper objectMapperValue) { SlackMessageFormat.serverEnv = serverEnvValue; + SlackMessageFormat.grafanaBaseUrl = grafanaBaseUrlValue; + SlackMessageFormat.lokiDatasourceUid = lokiDatasourceUidValue; + SlackMessageFormat.objectMapper = objectMapperValue; } public static String sendReport(Report report) { @@ -113,6 +131,50 @@ public static String sendServerError(Throwable ex, String method, String uri, St userId, args != null && args.length() > 500 ? args.substring(0, 500) + "...(truncated)" : args }; - return messageFormat.format(formatArgs); + String message = messageFormat.format(formatArgs); + + String logLink = buildGrafanaLogLink(); + if (logLink != null) { + message += "\n" + logLink; + } + return message; + } + + private static String buildGrafanaLogLink() { + String requestId = MDC.get("requestId"); + if (grafanaBaseUrl == null || grafanaBaseUrl.isBlank() + || lokiDatasourceUid == null || lokiDatasourceUid.isBlank() + || requestId == null || requestId.isBlank()) { + return null; + } + + String application = "eatssu-" + serverEnv; + String logQl = "{application=\"" + application + "\"} |= \"reqId=" + requestId + "\""; + long now = System.currentTimeMillis(); + + Map query = new LinkedHashMap<>(); + query.put("refId", "A"); + query.put("expr", logQl); + query.put("datasource", Map.of("type", "loki", "uid", lokiDatasourceUid)); + query.put("editorMode", "code"); + + Map range = new LinkedHashMap<>(); + range.put("from", String.valueOf(now - LOG_LINK_WINDOW_MILLIS)); + range.put("to", String.valueOf(now + LOG_LINK_WINDOW_MILLIS)); + + Map pane = new LinkedHashMap<>(); + pane.put("datasource", lokiDatasourceUid); + pane.put("queries", List.of(query)); + pane.put("range", range); + pane.put("compact", false); + + try { + String panesJson = objectMapper.writeValueAsString(Map.of("wes", pane)); + String encodedPanes = URLEncoder.encode(panesJson, StandardCharsets.UTF_8); + String url = grafanaBaseUrl + "/explore?schemaVersion=1&panes=" + encodedPanes; + return "<" + url + "|Grafana에서 로그 보기>"; + } catch (Exception e) { + return null; + } } } diff --git a/src/main/java/ssu/eatssu/global/log/ControllerLogAspect.java b/src/main/java/ssu/eatssu/global/log/ControllerLogAspect.java index 21af77f4..9b6f7824 100644 --- a/src/main/java/ssu/eatssu/global/log/ControllerLogAspect.java +++ b/src/main/java/ssu/eatssu/global/log/ControllerLogAspect.java @@ -17,6 +17,7 @@ import ssu.eatssu.domain.auth.security.CustomUserDetails; import ssu.eatssu.domain.slack.service.SlackErrorNotifier; import ssu.eatssu.global.handler.response.BaseException; +import ssu.eatssu.global.handler.response.BaseResponseStatus; import ssu.eatssu.global.log.annotation.LogMask; import java.lang.reflect.Field; @@ -99,7 +100,9 @@ public Object logApi(ProceedingJoinPoint joinPoint) throws Throwable { String exceptionType = e.getClass().getSimpleName(); log.error("EXCEPTION {} {} ({} ms) type={} cause={}", method, uri, time, exceptionType, causeMessage, e); - slackErrorNotifier.notify(e, method, uri, userId, argsJson); + if (shouldNotifySlack(e)) { + slackErrorNotifier.notify(e, method, uri, userId, argsJson); + } throw e; } } @@ -124,6 +127,13 @@ private boolean isAuthApi(String uri) { return uri != null && uri.startsWith("/oauths/"); } + private boolean shouldNotifySlack(Throwable e) { + if (e instanceof BaseException baseException) { + return BaseResponseStatus.sendSlackNotification(baseException.getStatus()); + } + return true; + } + private String getCauseMessage(Throwable e) { if (e instanceof BaseException baseException) { return baseException.getStatus().getMessage(); diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index e97dde3e..5286c067 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -77,6 +77,8 @@ deepl: swagger: url: ${EATSSU_DEV_DOMAIN} description: Test Server Swagger API + username: ${SWAGGER_USER} + password: ${SWAGGER_PASSWORD} springdoc: swagger-ui: diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index 6b38fd4d..e3bacb43 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -78,6 +78,8 @@ deepl: swagger: url: ${EATSSU_PROD_DOMAIN} description: Prod-Server url + username: ${SWAGGER_USER} + password: ${SWAGGER_PASSWORD} springdoc: swagger-ui: diff --git a/src/main/resources/logback-spring.xml b/src/main/resources/logback-spring.xml index 28f81a9d..0c65a545 100644 --- a/src/main/resources/logback-spring.xml +++ b/src/main/resources/logback-spring.xml @@ -8,6 +8,9 @@ Thread : 동시성 문제 관련 사용, Request : 요청 단위 --> + + @@ -22,8 +25,33 @@ + + + + /var/log/eatssu/app.log + + ${FILE_LOG_PATTERN} + + + /var/log/eatssu/app.%d{yyyy-MM-dd}.%i.log + 100MB + 3 + 1GB + + + + + true + + + + + + + diff --git a/src/test/java/ssu/eatssu/domain/auth/infrastructure/SecurityConfigTest.java b/src/test/java/ssu/eatssu/domain/auth/infrastructure/SecurityConfigTest.java new file mode 100644 index 00000000..8d2f2201 --- /dev/null +++ b/src/test/java/ssu/eatssu/domain/auth/infrastructure/SecurityConfigTest.java @@ -0,0 +1,30 @@ +package ssu.eatssu.domain.auth.infrastructure; + +import org.junit.jupiter.api.Test; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.crypto.password.PasswordEncoder; +import ssu.eatssu.domain.auth.security.JwtTokenProvider; +import ssu.eatssu.global.handler.JwtAccessDeniedHandler; +import ssu.eatssu.global.handler.JwtAuthenticationEntryPoint; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +class SecurityConfigTest { + + private final SecurityConfig securityConfig = new SecurityConfig( + mock(JwtTokenProvider.class), mock(JwtAccessDeniedHandler.class), mock(JwtAuthenticationEntryPoint.class)); + + @Test + void swaggerUserDetailsServiceEncodesPasswordAndExposesUsername() { + PasswordEncoder passwordEncoder = securityConfig.passwordEncoder(); + + UserDetailsService userDetailsService = + securityConfig.swaggerUserDetailsService("eatssu-dev", "eatssu2026!", passwordEncoder); + UserDetails user = userDetailsService.loadUserByUsername("eatssu-dev"); + + assertThat(user.getUsername()).isEqualTo("eatssu-dev"); + assertThat(passwordEncoder.matches("eatssu2026!", user.getPassword())).isTrue(); + } +} diff --git a/src/test/java/ssu/eatssu/domain/auth/infrastructure/SecurityFilterChainTest.java b/src/test/java/ssu/eatssu/domain/auth/infrastructure/SecurityFilterChainTest.java new file mode 100644 index 00000000..1a353db3 --- /dev/null +++ b/src/test/java/ssu/eatssu/domain/auth/infrastructure/SecurityFilterChainTest.java @@ -0,0 +1,88 @@ +package ssu.eatssu.domain.auth.infrastructure; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.test.web.servlet.MockMvc; +import ssu.eatssu.domain.auth.security.CustomUserDetails; +import ssu.eatssu.domain.auth.security.JwtTokenProvider; +import ssu.eatssu.domain.user.entity.DeviceType; +import ssu.eatssu.domain.user.entity.Role; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.httpBasic; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest(properties = { + "logging.config=classpath:logback-security-test.xml", + "spring.flyway.enabled=false", + "swagger.username=test-swagger", + "swagger.password=test-password" +}) +@ActiveProfiles({"dev", "test"}) +@AutoConfigureMockMvc +class SecurityFilterChainTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private JwtTokenProvider jwtTokenProvider; + + private String accessTokenWithRole(Role role) { + CustomUserDetails principal = new CustomUserDetails(1L, "test@eatssu.com", "", role, DeviceType.IOS); + Authentication authentication = + new UsernamePasswordAuthenticationToken(principal, null, principal.getAuthorities()); + return jwtTokenProvider.generateTokens(authentication).accessToken(); + } + + @Test + void 화이트리스트_경로는_인증_없이_시큐리티를_통과한다() throws Exception { + mockMvc.perform(get("/menus")) + .andExpect(status().isBadRequest()); + } + + @Test + void 화이트리스트에_없는_경로는_인증하지_않으면_401을_반환한다() throws Exception { + mockMvc.perform(get("/protected-test-path")) + .andExpect(status().isUnauthorized()); + } + + @Test + void 관리자_경로는_인증하지_않으면_401을_반환한다() throws Exception { + mockMvc.perform(get("/admin/anything")) + .andExpect(status().isUnauthorized()); + } + + @Test + void 관리자_경로는_ADMIN_권한이_없으면_403을_반환한다() throws Exception { + String token = accessTokenWithRole(Role.USER); + + mockMvc.perform(get("/admin/anything").header("Authorization", "Bearer " + token)) + .andExpect(status().isForbidden()); + } + + @Test + void 관리자_경로는_ADMIN_권한이_있으면_시큐리티를_통과한다() throws Exception { + String token = accessTokenWithRole(Role.ADMIN); + + mockMvc.perform(get("/admin/anything").header("Authorization", "Bearer " + token)) + .andExpect(status().isNotFound()); + } + + @Test + void Swagger_경로는_Basic_Auth_없이는_401을_반환한다() throws Exception { + mockMvc.perform(get("/swagger-ui/not-found.css")) + .andExpect(status().isUnauthorized()); + } + + @Test + void Swagger_경로는_유효한_Basic_Auth로_접근할_수_있다() throws Exception { + mockMvc.perform(get("/swagger-ui/not-found.css").with(httpBasic("test-swagger", "test-password"))) + .andExpect(status().isNotFound()); + } +} diff --git a/src/test/java/ssu/eatssu/domain/slack/entity/SlackMessageFormatTest.java b/src/test/java/ssu/eatssu/domain/slack/entity/SlackMessageFormatTest.java index fc9fda04..4a14f9fb 100644 --- a/src/test/java/ssu/eatssu/domain/slack/entity/SlackMessageFormatTest.java +++ b/src/test/java/ssu/eatssu/domain/slack/entity/SlackMessageFormatTest.java @@ -3,6 +3,7 @@ import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.slf4j.MDC; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.test.util.ReflectionTestUtils; @@ -54,11 +55,17 @@ class SlackMessageFormatTest { @BeforeEach void setUp() { ReflectionTestUtils.setField(SlackMessageFormat.class, "serverEnv", "test"); + ReflectionTestUtils.setField(SlackMessageFormat.class, "grafanaBaseUrl", ""); + ReflectionTestUtils.setField(SlackMessageFormat.class, "lokiDatasourceUid", ""); + MDC.remove("requestId"); cleanUp(); } @AfterEach void tearDown() { + ReflectionTestUtils.setField(SlackMessageFormat.class, "grafanaBaseUrl", ""); + ReflectionTestUtils.setField(SlackMessageFormat.class, "lokiDatasourceUid", ""); + MDC.remove("requestId"); cleanUp(); } @@ -87,6 +94,32 @@ void sendServerErrorFormatsRegularExceptionAndTruncatesLongArguments() { assertThat(message).contains("예외 타입", "IllegalStateException", "메시지 없음", "...(truncated)"); } + @Test + void sendServerErrorOmitsGrafanaLinkWhenNotConfigured() { + // given: grafana.base-url is left unconfigured (default test setup) + + // when + String message = SlackMessageFormat.sendServerError(new IllegalStateException(), "GET", "/menus", "1", null); + + // then + assertThat(message).doesNotContain("Grafana에서 로그 보기"); + } + + @Test + void sendServerErrorIncludesGrafanaLogLinkWhenConfiguredAndRequestIdPresent() { + // given + ReflectionTestUtils.setField(SlackMessageFormat.class, "grafanaBaseUrl", "https://example.grafana.net"); + ReflectionTestUtils.setField(SlackMessageFormat.class, "lokiDatasourceUid", "grafanacloud-logs"); + MDC.put("requestId", "test-request-id"); + + // when + String message = SlackMessageFormat.sendServerError(new IllegalStateException(), "GET", "/menus", "1", null); + + // then + assertThat(message).contains("https://example.grafana.net/explore?schemaVersion=1&panes=", + "Grafana에서 로그 보기"); + } + @Test void slackChannelsExposeConfiguredAddress() { assertThat(SlackChannel.SERVER_ERROR.getKrName()).isEqualTo("C092J4J6F0U"); diff --git a/src/test/java/ssu/eatssu/global/log/ControllerLogAspectTest.java b/src/test/java/ssu/eatssu/global/log/ControllerLogAspectTest.java index 5614a86a..be0606ff 100644 --- a/src/test/java/ssu/eatssu/global/log/ControllerLogAspectTest.java +++ b/src/test/java/ssu/eatssu/global/log/ControllerLogAspectTest.java @@ -111,7 +111,7 @@ void shouldMaskAnnotatedRequestFields() throws Exception { } @Test - void 컨트롤러_예외를_슬랙에_알리고_다시_던진다() throws Throwable { + void 컨트롤러_5xx_예외를_슬랙에_알리고_다시_던진다() throws Throwable { SlackErrorNotifier notifier = mock(SlackErrorNotifier.class); ControllerLogAspect aspect = new ControllerLogAspect(new ObjectMapper(), notifier); ProceedingJoinPoint joinPoint = mock(ProceedingJoinPoint.class); @@ -121,12 +121,29 @@ void shouldMaskAnnotatedRequestFields() throws Exception { when(joinPoint.getSignature()).thenReturn(signature); when(signature.getParameterNames()).thenReturn(new String[0]); when(joinPoint.getArgs()).thenReturn(new Object[0]); - when(joinPoint.proceed()).thenThrow(new BaseException(BaseResponseStatus.NOT_FOUND_MENU)); + when(joinPoint.proceed()).thenThrow(new BaseException(BaseResponseStatus.INTERNAL_SERVER_ERROR)); assertThatThrownBy(() -> aspect.logApi(joinPoint)).isInstanceOf(BaseException.class); verify(notifier).notify(any(), anyString(), anyString(), anyString(), anyString()); } + @Test + void doesNotNotifySlackFor4xxBusinessException() throws Throwable { + SlackErrorNotifier notifier = mock(SlackErrorNotifier.class); + ControllerLogAspect aspect = new ControllerLogAspect(new ObjectMapper(), notifier); + ProceedingJoinPoint joinPoint = mock(ProceedingJoinPoint.class); + MethodSignature signature = mock(MethodSignature.class); + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/menus"); + RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request)); + when(joinPoint.getSignature()).thenReturn(signature); + when(signature.getParameterNames()).thenReturn(new String[0]); + when(joinPoint.getArgs()).thenReturn(new Object[0]); + when(joinPoint.proceed()).thenThrow(new BaseException(BaseResponseStatus.NOT_FOUND_MENU)); + + assertThatThrownBy(() -> aspect.logApi(joinPoint)).isInstanceOf(BaseException.class); + verify(notifier, org.mockito.Mockito.never()).notify(any(), anyString(), anyString(), anyString(), anyString()); + } + @Test void 제외_인자와_null_인자와_긴_인자를_안전하게_로그한다() throws Throwable { ProceedingJoinPoint joinPoint = mock(ProceedingJoinPoint.class); diff --git a/src/test/resources/logback-security-test.xml b/src/test/resources/logback-security-test.xml new file mode 100644 index 00000000..49fc35c7 --- /dev/null +++ b/src/test/resources/logback-security-test.xml @@ -0,0 +1,11 @@ + + + + + %msg%n + + + + + +