diff --git a/src/main/java/com/jobdri/jobdri_api/domain/analysis/controller/AnalysisWorkerInternalController.java b/src/main/java/com/jobdri/jobdri_api/domain/analysis/controller/AnalysisWorkerInternalController.java index 4cbc916a..ffd4ba77 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/analysis/controller/AnalysisWorkerInternalController.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/analysis/controller/AnalysisWorkerInternalController.java @@ -13,7 +13,6 @@ import com.jobdri.jobdri_api.domain.analysis.service.async.AnalysisWorkerBridgeService; import com.jobdri.jobdri_api.domain.workerresult.dto.WorkerTaskResultResponse; import com.jobdri.jobdri_api.global.apiPayload.ApiResponse; -import com.jobdri.jobdri_api.global.security.InternalApiKeyValidator; import io.swagger.v3.oas.annotations.Hidden; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; @@ -23,7 +22,6 @@ import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @@ -34,20 +32,15 @@ @Tag(name = "Analysis Worker Internal", description = "자소서 분석 worker 내부 통신 API") public class AnalysisWorkerInternalController { - private static final String INTERNAL_API_KEY_HEADER = "X-Internal-Api-Key"; - - private final InternalApiKeyValidator internalApiKeyValidator; private final AnalysisWorkerBridgeService analysisWorkerBridgeService; private final AnalysisAsyncTaskService analysisAsyncTaskService; @Operation(summary = "자소서 분석 worker 작업 실행 상태 반영", description = "worker가 taskId 기준 자소서 분석 작업을 실행 중 상태로 변경합니다.") @PostMapping("/tasks/{taskId}/running") public ApiResponse markRunning( - @RequestHeader(INTERNAL_API_KEY_HEADER) String internalApiKey, @PathVariable String taskId, @Valid @RequestBody AnalysisWorkerRunningRequest request ) { - internalApiKeyValidator.validate(internalApiKey); analysisWorkerBridgeService.markRunning(taskId, request.workerId(), request.retryCount(), request.submittedAt()); return ApiResponse.onSuccess("자소서 분석 worker 작업 시작 상태를 반영했습니다."); } @@ -55,11 +48,9 @@ public ApiResponse markRunning( @Operation(summary = "자소서 분석 worker 작업 재시도 상태 반영", description = "worker가 자소서 분석 작업 실패 후 재시도 상태와 메타데이터를 반영합니다.") @PostMapping("/tasks/{taskId}/retry") public ApiResponse markRetry( - @RequestHeader(INTERNAL_API_KEY_HEADER) String internalApiKey, @PathVariable String taskId, @Valid @RequestBody AnalysisWorkerRetryRequest request ) { - internalApiKeyValidator.validate(internalApiKey); analysisWorkerBridgeService.markRetry( taskId, request.failureReason(), @@ -74,11 +65,9 @@ public ApiResponse markRetry( @Operation(summary = "자소서 분석 worker 작업 실패 반영", description = "worker가 자소서 분석 작업 실패 상태와 실패 메타데이터를 반영합니다.") @PostMapping("/tasks/{taskId}/failed") public ApiResponse failTask( - @RequestHeader(INTERNAL_API_KEY_HEADER) String internalApiKey, @PathVariable String taskId, @Valid @RequestBody AnalysisWorkerFailureRequest request ) { - internalApiKeyValidator.validate(internalApiKey); analysisWorkerBridgeService.failTask( taskId, request.failureReason(), @@ -93,10 +82,8 @@ public ApiResponse failTask( @Operation(summary = "자소서 분석 worker 컨텍스트 조회", description = "worker가 분석 실행에 필요한 자소서, 공고, 지원 정보 컨텍스트를 조회합니다.") @PostMapping("/context") public ApiResponse getContext( - @RequestHeader(INTERNAL_API_KEY_HEADER) String internalApiKey, @Valid @RequestBody AnalysisWorkerContextRequest request ) { - internalApiKeyValidator.validate(internalApiKey); return ApiResponse.onSuccess( "자소서 분석 worker 컨텍스트 조회에 성공했습니다.", analysisWorkerBridgeService.getContext(request.taskId(), request.userId(), request.mockApplyId()) @@ -106,11 +93,9 @@ public ApiResponse getContext( @Operation(summary = "자소서 분석 worker 작업 완료 반영", description = "worker가 생성한 분석 결과를 저장하고 taskId 기준 작업 완료 상태를 반영합니다.") @PostMapping("/tasks/{taskId}/complete") public ApiResponse completeTask( - @RequestHeader(INTERNAL_API_KEY_HEADER) String internalApiKey, @PathVariable String taskId, @Valid @RequestBody AnalysisWorkerCompleteRequest request ) { - internalApiKeyValidator.validate(internalApiKey); return ApiResponse.onSuccess( "자소서 분석 worker 작업 완료 상태를 반영했습니다.", analysisWorkerBridgeService.completeTask(taskId, request) @@ -120,11 +105,9 @@ public ApiResponse completeTask( @Operation(summary = "자소서 분석 worker 결과 선저장", description = "worker가 complete 호출 전에 taskId 기준 분석 결과를 durable storage에 저장합니다.") @PostMapping("/tasks/{taskId}/result") public ApiResponse storeResult( - @RequestHeader(INTERNAL_API_KEY_HEADER) String internalApiKey, @PathVariable String taskId, @Valid @RequestBody AnalysisWorkerResultStoreRequest request ) { - internalApiKeyValidator.validate(internalApiKey); analysisWorkerBridgeService.storeGeneratedResult(taskId, request); return ApiResponse.onSuccess("자소서 분석 worker 결과 선저장에 성공했습니다."); } @@ -132,10 +115,8 @@ public ApiResponse storeResult( @Operation(summary = "자소서 분석 worker 저장 결과 조회", description = "worker가 taskId 기준으로 저장된 분석 결과 payload를 조회합니다.") @GetMapping("/tasks/{taskId}/result") public ApiResponse getStoredResult( - @RequestHeader(INTERNAL_API_KEY_HEADER) String internalApiKey, @PathVariable String taskId ) { - internalApiKeyValidator.validate(internalApiKey); return ApiResponse.onSuccess( "자소서 분석 worker 저장 결과 조회에 성공했습니다.", analysisWorkerBridgeService.getStoredResult(taskId) @@ -145,10 +126,8 @@ public ApiResponse getStoredResult( @Operation(summary = "자소서 분석 worker 작업 상태 조회", description = "taskId 기준 자소서 분석 worker 비동기 작업 상태를 내부 용도로 조회합니다.") @GetMapping("/tasks/{taskId}") public ApiResponse getTask( - @RequestHeader(INTERNAL_API_KEY_HEADER) String internalApiKey, @PathVariable String taskId ) { - internalApiKeyValidator.validate(internalApiKey); return ApiResponse.onSuccess( "자소서 분석 worker 작업 상태 조회에 성공했습니다.", analysisAsyncTaskService.getTaskStatusByTaskId(taskId) diff --git a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/controller/JobPostingWorkerInternalController.java b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/controller/JobPostingWorkerInternalController.java index 031ffe9e..3b7ca25f 100644 --- a/src/main/java/com/jobdri/jobdri_api/domain/jobposting/controller/JobPostingWorkerInternalController.java +++ b/src/main/java/com/jobdri/jobdri_api/domain/jobposting/controller/JobPostingWorkerInternalController.java @@ -15,7 +15,6 @@ import com.jobdri.jobdri_api.domain.jobposting.service.JobPostingWorkerBridgeService; import com.jobdri.jobdri_api.domain.workerresult.dto.WorkerTaskResultResponse; import com.jobdri.jobdri_api.global.apiPayload.ApiResponse; -import com.jobdri.jobdri_api.global.security.InternalApiKeyValidator; import io.swagger.v3.oas.annotations.Hidden; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; @@ -25,7 +24,6 @@ import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @@ -38,20 +36,15 @@ @Tag(name = "JobPosting Worker Internal", description = "채용 공고 worker 내부 통신 API") public class JobPostingWorkerInternalController { - private static final String INTERNAL_API_KEY_HEADER = "X-Internal-Api-Key"; - - private final InternalApiKeyValidator internalApiKeyValidator; private final JobPostingWorkerBridgeService jobPostingWorkerBridgeService; private final JobPostingAsyncFacadeService jobPostingAsyncFacadeService; @Operation(summary = "채용 공고 worker 작업 실행 상태 반영", description = "worker가 taskId 기준 채용 공고 작업을 실행 중 상태로 변경합니다.") @PostMapping("/tasks/{taskId}/running") public ApiResponse markRunning( - @RequestHeader(INTERNAL_API_KEY_HEADER) String internalApiKey, @PathVariable String taskId, @Valid @RequestBody JobPostingWorkerRunningRequest request ) { - internalApiKeyValidator.validate(internalApiKey); jobPostingWorkerBridgeService.markRunning(taskId, request.workerId(), request.retryCount(), request.submittedAt()); return ApiResponse.onSuccess("채용 공고 worker 작업 시작 상태를 반영했습니다."); } @@ -59,11 +52,9 @@ public ApiResponse markRunning( @Operation(summary = "채용 공고 worker 작업 재시도 상태 반영", description = "worker가 채용 공고 작업 실패 후 재시도 상태와 메타데이터를 반영합니다.") @PostMapping("/tasks/{taskId}/retry") public ApiResponse markRetry( - @RequestHeader(INTERNAL_API_KEY_HEADER) String internalApiKey, @PathVariable String taskId, @Valid @RequestBody JobPostingWorkerRetryRequest request ) { - internalApiKeyValidator.validate(internalApiKey); jobPostingWorkerBridgeService.markRetry( taskId, request.failureReason(), @@ -78,11 +69,9 @@ public ApiResponse markRetry( @Operation(summary = "채용 공고 worker 작업 완료 반영", description = "legacy worker가 조립한 최종 응답으로 taskId 기준 작업 완료 상태를 반영합니다.") @PostMapping("/tasks/{taskId}/complete") public ApiResponse completeTask( - @RequestHeader(INTERNAL_API_KEY_HEADER) String internalApiKey, @PathVariable String taskId, @Valid @RequestBody JobPostingIngestResponse result ) { - internalApiKeyValidator.validate(internalApiKey); return ApiResponse.onSuccess( "채용 공고 worker 작업 완료 상태를 반영했습니다.", jobPostingWorkerBridgeService.completeTask(taskId, result) @@ -92,11 +81,9 @@ public ApiResponse completeTask( @Operation(summary = "채용 공고 worker 결과 선저장", description = "worker가 finalize 호출 전에 taskId 기준 채용 공고 결과를 durable storage에 저장합니다.") @PostMapping("/tasks/{taskId}/result") public ApiResponse storeResult( - @RequestHeader(INTERNAL_API_KEY_HEADER) String internalApiKey, @PathVariable String taskId, @Valid @RequestBody JobPostingWorkerResultStoreRequest request ) { - internalApiKeyValidator.validate(internalApiKey); jobPostingWorkerBridgeService.storeFinalizeResult(taskId, request); return ApiResponse.onSuccess("채용 공고 worker 결과 선저장에 성공했습니다."); } @@ -104,10 +91,8 @@ public ApiResponse storeResult( @Operation(summary = "채용 공고 worker 저장 결과 조회", description = "worker가 taskId 기준으로 저장된 채용 공고 결과 payload를 조회합니다.") @GetMapping("/tasks/{taskId}/result") public ApiResponse getStoredResult( - @RequestHeader(INTERNAL_API_KEY_HEADER) String internalApiKey, @PathVariable String taskId ) { - internalApiKeyValidator.validate(internalApiKey); return ApiResponse.onSuccess( "채용 공고 worker 저장 결과 조회에 성공했습니다.", jobPostingWorkerBridgeService.getStoredResult(taskId) @@ -117,11 +102,9 @@ public ApiResponse getStoredResult( @Operation(summary = "채용 공고 worker 작업 실패 반영", description = "worker가 채용 공고 작업 실패 상태와 실패 메타데이터를 반영합니다.") @PostMapping("/tasks/{taskId}/failed") public ApiResponse failTask( - @RequestHeader(INTERNAL_API_KEY_HEADER) String internalApiKey, @PathVariable String taskId, @Valid @RequestBody JobPostingWorkerFailureRequest request ) { - internalApiKeyValidator.validate(internalApiKey); jobPostingWorkerBridgeService.failTask( taskId, request.failureReason(), @@ -136,10 +119,8 @@ public ApiResponse failTask( @Operation(summary = "채용 공고 worker 컨텍스트 조회", description = "worker가 이미지 기반 채용 공고 처리를 위해 읽기 가능한 컨텍스트 정보를 조회합니다.") @PostMapping("/ingest/context") public ApiResponse getContext( - @RequestHeader(INTERNAL_API_KEY_HEADER) String internalApiKey, @Valid @RequestBody JobPostingWorkerContextRequest request ) { - internalApiKeyValidator.validate(internalApiKey); List imageUrls = jobPostingWorkerBridgeService.createReadableImageUrls( request.userId(), request.imageObjectKey(), @@ -157,10 +138,8 @@ public ApiResponse getContext( @Operation(summary = "채용 공고 분류 후보 조회", description = "추출된 채용 공고 정보를 바탕으로 분류 후보 목록을 조회합니다.") @PostMapping("/classification/candidates") public ApiResponse> getCandidates( - @RequestHeader(INTERNAL_API_KEY_HEADER) String internalApiKey, @Valid @RequestBody JobPostingExtractResponse extracted ) { - internalApiKeyValidator.validate(internalApiKey); return ApiResponse.onSuccess( "채용 공고 분류 후보 조회에 성공했습니다.", jobPostingWorkerBridgeService.findCandidates(extracted) @@ -170,10 +149,8 @@ public ApiResponse> getCandidate @Operation(summary = "채용 공고 적재 후처리 및 완료", description = "추출, 분류, 생성 결과를 바탕으로 채용 공고 저장과 비동기 완료 처리를 한 번에 수행하는 주 성공 callback입니다.") @PostMapping("/ingest/finalize") public ApiResponse finalizeTask( - @RequestHeader(INTERNAL_API_KEY_HEADER) String internalApiKey, @Valid @RequestBody JobPostingWorkerFinalizeRequest request ) { - internalApiKeyValidator.validate(internalApiKey); return ApiResponse.onSuccess( "채용 공고 저장 및 비동기 완료 처리에 성공했습니다.", jobPostingWorkerBridgeService.finalizeAndComplete( @@ -190,10 +167,8 @@ public ApiResponse finalizeTask( @Operation(summary = "채용 공고 worker 작업 상태 조회", description = "taskId 기준 채용 공고 worker 비동기 작업 상태를 내부 용도로 조회합니다.") @GetMapping("/tasks/{taskId}") public ApiResponse getTask( - @RequestHeader(INTERNAL_API_KEY_HEADER) String internalApiKey, @PathVariable String taskId ) { - internalApiKeyValidator.validate(internalApiKey); return ApiResponse.onSuccess( "채용 공고 worker 작업 상태 조회에 성공했습니다.", jobPostingAsyncFacadeService.getTaskInternal(taskId) diff --git a/src/main/java/com/jobdri/jobdri_api/global/config/SecurityConfig.java b/src/main/java/com/jobdri/jobdri_api/global/config/SecurityConfig.java index 919495ea..22d2afb2 100644 --- a/src/main/java/com/jobdri/jobdri_api/global/config/SecurityConfig.java +++ b/src/main/java/com/jobdri/jobdri_api/global/config/SecurityConfig.java @@ -9,6 +9,8 @@ import com.jobdri.jobdri_api.global.jwt.JwtAuthenticationFilter; import com.jobdri.jobdri_api.global.jwt.JwtUtil; import com.jobdri.jobdri_api.global.metrics.AuthRedisMetricsRecorder; +import com.jobdri.jobdri_api.global.security.InternalApiKeyValidator; +import com.jobdri.jobdri_api.global.security.InternalWorkerApiKeyFilter; import com.jobdri.jobdri_api.global.security.UserDetailsServiceImpl; import com.jobdri.jobdri_api.global.logging.RequestContextLoggingFilter; import lombok.RequiredArgsConstructor; @@ -24,6 +26,7 @@ import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; import org.springframework.security.web.context.RequestAttributeSecurityContextRepository; import org.springframework.web.cors.CorsConfigurationSource; +import com.fasterxml.jackson.databind.ObjectMapper; import java.util.List; @@ -52,6 +55,14 @@ public PortOneWebhookRateLimitFilter portOneWebhookRateLimitFilter() { return new PortOneWebhookRateLimitFilter(); } + @Bean + public InternalWorkerApiKeyFilter internalWorkerApiKeyFilter( + InternalApiKeyValidator internalApiKeyValidator, + ObjectMapper objectMapper + ) { + return new InternalWorkerApiKeyFilter(internalApiKeyValidator, objectMapper); + } + @Bean public RequestContextLoggingFilter requestContextLoggingFilter( @Value("${app.logging.request-id-max-length:64}") int requestIdMaxLength, @@ -87,12 +98,22 @@ public FilterRegistrationBean portOneWebhookRateL return registration; } + @Bean + public FilterRegistrationBean internalWorkerApiKeyFilterRegistration( + InternalWorkerApiKeyFilter internalWorkerApiKeyFilter + ) { + FilterRegistrationBean registration = new FilterRegistrationBean<>(internalWorkerApiKeyFilter); + registration.setEnabled(false); + return registration; + } + @Bean public SecurityFilterChain securityFilterChain( HttpSecurity http, RequestContextLoggingFilter requestContextLoggingFilter, JwtAuthenticationFilter jwtAuthenticationFilter, - PortOneWebhookRateLimitFilter portOneWebhookRateLimitFilter + PortOneWebhookRateLimitFilter portOneWebhookRateLimitFilter, + InternalWorkerApiKeyFilter internalWorkerApiKeyFilter ) throws Exception { http.cors((cors) -> cors.configurationSource(corsConfigurationSource())); @@ -128,6 +149,7 @@ public SecurityFilterChain securityFilterChain( ); http.addFilterBefore(portOneWebhookRateLimitFilter, UsernamePasswordAuthenticationFilter.class); + http.addFilterBefore(internalWorkerApiKeyFilter, PortOneWebhookRateLimitFilter.class); http.addFilterBefore(requestContextLoggingFilter, UsernamePasswordAuthenticationFilter.class); http.addFilterAfter(jwtAuthenticationFilter, RequestContextLoggingFilter.class); diff --git a/src/main/java/com/jobdri/jobdri_api/global/security/InternalWorkerApiKeyFilter.java b/src/main/java/com/jobdri/jobdri_api/global/security/InternalWorkerApiKeyFilter.java new file mode 100644 index 00000000..ea34680a --- /dev/null +++ b/src/main/java/com/jobdri/jobdri_api/global/security/InternalWorkerApiKeyFilter.java @@ -0,0 +1,63 @@ +package com.jobdri.jobdri_api.global.security; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.jobdri.jobdri_api.global.apiPayload.ApiResponse; +import com.jobdri.jobdri_api.global.apiPayload.code.GeneralErrorCode; +import com.jobdri.jobdri_api.global.apiPayload.exception.GeneralException; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +public class InternalWorkerApiKeyFilter extends OncePerRequestFilter { + + private static final String INTERNAL_WORKER_PATH_PREFIX = "/api/internal/worker/"; + private static final String INTERNAL_API_KEY_HEADER = "X-Internal-Api-Key"; + + private final InternalApiKeyValidator internalApiKeyValidator; + private final ObjectMapper objectMapper; + + public InternalWorkerApiKeyFilter( + InternalApiKeyValidator internalApiKeyValidator, + ObjectMapper objectMapper + ) { + this.internalApiKeyValidator = internalApiKeyValidator; + this.objectMapper = objectMapper; + } + + @Override + protected boolean shouldNotFilter(HttpServletRequest request) { + return !request.getRequestURI().startsWith(INTERNAL_WORKER_PATH_PREFIX); + } + + @Override + protected void doFilterInternal( + HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain + ) throws ServletException, IOException { + try { + internalApiKeyValidator.validate(request.getHeader(INTERNAL_API_KEY_HEADER)); + } catch (GeneralException exception) { + writeForbiddenResponse(response, exception); + return; + } + + filterChain.doFilter(request, response); + } + + private void writeForbiddenResponse(HttpServletResponse response, GeneralException exception) throws IOException { + response.setStatus(GeneralErrorCode.FORBIDDEN.getHttpStatus().value()); + response.setContentType("application/json;charset=UTF-8"); + objectMapper.writeValue( + response.getWriter(), + ApiResponse.onFailure( + GeneralErrorCode.FORBIDDEN, + exception.getError() != null ? exception.getError() : exception.getMessage() + ) + ); + } +} diff --git a/src/test/java/com/jobdri/jobdri_api/global/security/InternalWorkerApiKeyFilterIntegrationTest.java b/src/test/java/com/jobdri/jobdri_api/global/security/InternalWorkerApiKeyFilterIntegrationTest.java new file mode 100644 index 00000000..011d7f9a --- /dev/null +++ b/src/test/java/com/jobdri/jobdri_api/global/security/InternalWorkerApiKeyFilterIntegrationTest.java @@ -0,0 +1,82 @@ +package com.jobdri.jobdri_api.global.security; + +import com.jobdri.jobdri_api.domain.analysis.dto.response.AnalysisAsyncStatusResponse; +import com.jobdri.jobdri_api.domain.analysis.service.async.AnalysisAsyncTaskService; +import org.junit.jupiter.api.DisplayName; +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.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest(properties = "app.worker.internal-api-key=test-internal-api-key") +@AutoConfigureMockMvc +@ActiveProfiles("test") +class InternalWorkerApiKeyFilterIntegrationTest { + + private static final String INTERNAL_API_KEY_HEADER = "X-Internal-Api-Key"; + + @Autowired + private MockMvc mockMvc; + + @MockitoBean + private AnalysisAsyncTaskService analysisAsyncTaskService; + + @Test + @DisplayName("내부 worker API는 헤더가 없으면 403을 반환한다") + void rejectsRequestWithoutInternalApiKey() throws Exception { + mockMvc.perform(get("/api/internal/worker/analysis/tasks/test-task")) + .andExpect(status().isForbidden()) + .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$.isSuccess").value(false)) + .andExpect(jsonPath("$.code").value("AUTH_4031")) + .andExpect(jsonPath("$.message").value("접근 권한이 없습니다.")) + .andExpect(jsonPath("$.error").value("내부 worker 인증에 실패했습니다.")); + + verifyNoInteractions(analysisAsyncTaskService); + } + + @Test + @DisplayName("내부 worker API는 잘못된 키면 403을 반환한다") + void rejectsRequestWithInvalidInternalApiKey() throws Exception { + mockMvc.perform(get("/api/internal/worker/analysis/tasks/test-task") + .header(INTERNAL_API_KEY_HEADER, "wrong-key")) + .andExpect(status().isForbidden()) + .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$.isSuccess").value(false)) + .andExpect(jsonPath("$.code").value("AUTH_4031")) + .andExpect(jsonPath("$.message").value("접근 권한이 없습니다.")) + .andExpect(jsonPath("$.error").value("내부 worker 인증에 실패했습니다.")); + + verifyNoInteractions(analysisAsyncTaskService); + } + + @Test + @DisplayName("내부 worker API는 올바른 키면 컨트롤러까지 요청을 전달한다") + void allowsRequestWithValidInternalApiKey() throws Exception { + when(analysisAsyncTaskService.getTaskStatusByTaskId(anyString())) + .thenReturn(AnalysisAsyncStatusResponse.builder() + .taskId("test-task") + .status("RUNNING") + .message("processing") + .build()); + + mockMvc.perform(get("/api/internal/worker/analysis/tasks/test-task") + .header(INTERNAL_API_KEY_HEADER, "test-internal-api-key")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.isSuccess").value(true)) + .andExpect(jsonPath("$.result.taskId").value("test-task")) + .andExpect(jsonPath("$.result.status").value("RUNNING")); + } +}