ITN Dev 07-22
fix: 잘못된 요청을 500 대신 4xx로 응답하고 부분 실패 채널생성 경계 테스트 추가
- GlobalExceptionHandler(@RestControllerAdvice) 추가: IllegalArgumentException은
  404, SeedParseException은 400으로 매핑하고 예외 메시지를 ApiError로 그대로
  돌려준다. Exception 전체를 잡는 catch-all은 두지 않아 예상 밖의 진짜 버그는
  여전히 500으로 드러나게 한다.
- SeedController 업로드에 확장자(.xlsx/.xlsm) 검사를 추가해 임의의 바이트가
  Apache POI로 그대로 전달되기 전에 SeedParseException으로 걸러낸다.
- POST /api/orgs/{id}/channels의 실제 혼합 성공/실패 결과가 200으로 내려가는
  경로를 컨트롤러 경계에서 검증하는 테스트를 추가하고, 담당자 정보 누락
  테스트의 law 단정을 보강했다.
@4280ec60720e26e31d1d3be5edd5581a9323fe13
 
src/main/java/kr/itn/itnhub/config/ApiError.java (added)
+++ src/main/java/kr/itn/itnhub/config/ApiError.java
@@ -0,0 +1,8 @@
+package kr.itn.itnhub.config;
+
+/**
+ * 클라이언트 오류(4xx) 응답 본문. 사람이 읽을 수 있는 메시지 하나만 담는다.
+ * 예외 자체의 메시지를 그대로 옮길 뿐 스택트레이스·SQL·파일 경로는 절대 포함하지 않는다.
+ */
+public record ApiError(String message) {
+}
 
src/main/java/kr/itn/itnhub/config/GlobalExceptionHandler.java (added)
+++ src/main/java/kr/itn/itnhub/config/GlobalExceptionHandler.java
@@ -0,0 +1,40 @@
+package kr.itn.itnhub.config;
+
+import kr.itn.itnhub.seed.SeedParseException;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.ExceptionHandler;
+import org.springframework.web.bind.annotation.RestControllerAdvice;
+
+/**
+ * 운영자의 흔한 실수를 서버 장애(500)가 아니라 4xx로 돌려준다.
+ *
+ * <p>{@link IllegalArgumentException}은 {@code OrganizationService.updateContact}와
+ * {@code ChannelProvisionService.provision}이 존재하지 않는 기관 id에 대해 던진다 -
+ * URL에 오타가 있는 것뿐이므로 404가 맞다.</p>
+ *
+ * <p>{@link SeedParseException}은 비어 있거나, 형식이 틀렸거나, 시트가 잘못된 업로드에
+ * 대해 던진다 - 흔한 사용자 실수이므로 400과 함께 무엇이 잘못됐는지 알려줘야 한다.</p>
+ *
+ * <p>예외 메시지는 각 예외가 만든 그대로 내려준다(새로 지어내지 않는다) - 스택트레이스,
+ * SQL, 파일 경로는 어차피 이 메시지들에 담기지 않으므로 그대로 노출해도 안전하다.
+ * {@code server.error.include-message=NEVER} 기본값은 건드리지 않는다 - 여기서 직접
+ * {@link ApiError} 본문을 만들어 반환하므로 그 설정과는 무관하게 동작한다.</p>
+ *
+ * <p><b>여기에 {@code Exception.class} catch-all을 추가하지 말 것.</b> 예상하지 못한
+ * 예외까지 4xx로 감싸버리면 진짜 버그가 조용히 묻힌다. 예상 밖 예외는 기본 500 처리
+ * 그대로 두는 것이 의도다.</p>
+ */
+@RestControllerAdvice
+public class GlobalExceptionHandler {
+
+    @ExceptionHandler(IllegalArgumentException.class)
+    public ResponseEntity<ApiError> handleNotFound(IllegalArgumentException e) {
+        return ResponseEntity.status(HttpStatus.NOT_FOUND).body(new ApiError(e.getMessage()));
+    }
+
+    @ExceptionHandler(SeedParseException.class)
+    public ResponseEntity<ApiError> handleSeedParse(SeedParseException e) {
+        return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(new ApiError(e.getMessage()));
+    }
+}
src/main/java/kr/itn/itnhub/seed/SeedController.java
--- src/main/java/kr/itn/itnhub/seed/SeedController.java
+++ src/main/java/kr/itn/itnhub/seed/SeedController.java
@@ -22,8 +22,20 @@
         if (file.isEmpty()) {
             throw new SeedParseException("업로드된 파일이 비어 있습니다.");
         }
+        // 확장자 검사만으로 파일 형식을 완전히 보장하진 못하지만, 엉뚱한 바이트를
+        // 곧장 Apache POI에 넘기기 전에 흔한 실수(다른 파일을 잘못 올림)를 걸러내고
+        // 사람이 이해할 수 있는 메시지로 알려준다.
+        String filename = file.getOriginalFilename();
+        if (filename == null || !hasAllowedExtension(filename)) {
+            throw new SeedParseException("엑셀 파일(.xlsx 또는 .xlsm)만 업로드할 수 있습니다.");
+        }
         try (InputStream in = file.getInputStream()) {
             return seedService.seed(in);
         }
     }
+
+    private static boolean hasAllowedExtension(String filename) {
+        String lower = filename.toLowerCase();
+        return lower.endsWith(".xlsx") || lower.endsWith(".xlsm");
+    }
 }
src/test/java/kr/itn/itnhub/org/OrganizationControllerTest.java
--- src/test/java/kr/itn/itnhub/org/OrganizationControllerTest.java
+++ src/test/java/kr/itn/itnhub/org/OrganizationControllerTest.java
@@ -2,6 +2,7 @@
 
 import kr.itn.itnhub.AbstractDbTest;
 import kr.itn.itnhub.mattermost.MattermostClient;
+import kr.itn.itnhub.mattermost.MattermostException;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
 import org.springframework.beans.factory.annotation.Autowired;
@@ -15,7 +16,9 @@
 import java.util.Optional;
 
 import static org.assertj.core.api.Assertions.assertThat;
+import static org.hamcrest.Matchers.containsString;
 import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
 import static org.mockito.Mockito.when;
 import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
 import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
@@ -85,6 +88,26 @@
     }
 
     @Test
+    void 존재하지_않는_기관ID로_담당자정보를_수정하면_404다() throws Exception {
+        long missingId = orgId + 999999L;
+
+        mvc.perform(put("/api/orgs/{id}/contact", missingId)
+                        .with(csrf())
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content("""
+                                {
+                                  "deptName": "데이터정보화팀",
+                                  "managerName": "송민지",
+                                  "managerTitle": "과장",
+                                  "managerPhone": "02-3475-5434",
+                                  "managerEmail": "ming@arirang.com"
+                                }
+                                """))
+                .andExpect(status().isNotFound())
+                .andExpect(jsonPath("$.message").value(containsString(String.valueOf(missingId))));
+    }
+
+    @Test
     void 필수값이_비면_400이다() throws Exception {
         mvc.perform(put("/api/orgs/{id}/contact", orgId)
                         .with(csrf())
@@ -125,7 +148,36 @@
         mvc.perform(post("/api/orgs/{id}/channels", orgId).with(csrf()))
                 .andExpect(status().isOk())
                 .andExpect(jsonPath("$.mj").value("FAILED"))
-                .andExpect(jsonPath("$.message").value(
-                        org.hamcrest.Matchers.containsString("담당자 정보")));
+                .andExpect(jsonPath("$.law").value("FAILED"))
+                .andExpect(jsonPath("$.message").value(containsString("담당자 정보")));
+    }
+
+    /**
+     * Finding 2 회귀 테스트: 문정원 채널은 성공하고 법률검토 채널만 실패하는 실제 혼합
+     * 결과를 컨트롤러 경계에서 검증한다. 기존 테스트는 전부 성공 아니면(둘 다 성공)
+     * 담당자 정보 누락으로 둘 다 강제 FAILED인 경로만 다뤄, 진짜 부분 실패가 HTTP
+     * 200으로 내려가는지는 한 번도 실행되지 않았다.
+     */
+    @Test
+    void 법률검토만_실패해도_200과_부분실패_결과를_돌려준다() throws Exception {
+        Organization ready = mapper.findById(orgId);
+        ready.setDeptName("데이터정보화팀");
+        ready.setManagerName("송민지");
+        ready.setManagerTitle("과장");
+        ready.setManagerPhone("02-3475-5434");
+        ready.setManagerEmail("ming@arirang.com");
+        mapper.updateContact(ready);
+
+        when(mattermost.findChannelIdByInternalName(anyString())).thenReturn(Optional.empty());
+        when(mattermost.findChannelIdByDisplayName(anyString())).thenReturn(Optional.empty());
+        when(mattermost.createPrivateChannel(eq("org-001-mj"), anyString())).thenReturn("id-mj");
+        when(mattermost.createPrivateChannel(eq("org-001-law"), anyString()))
+                .thenThrow(new MattermostException("법률검토 서버 오류"));
+
+        mvc.perform(post("/api/orgs/{id}/channels", orgId).with(csrf()))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.mj").value("CREATED"))
+                .andExpect(jsonPath("$.law").value("FAILED"))
+                .andExpect(jsonPath("$.message").value(containsString("법률검토")));
     }
 }
 
src/test/java/kr/itn/itnhub/seed/SeedControllerTest.java (added)
+++ src/test/java/kr/itn/itnhub/seed/SeedControllerTest.java
@@ -0,0 +1,77 @@
+package kr.itn.itnhub.seed;
+
+import kr.itn.itnhub.AbstractDbTest;
+import org.junit.jupiter.api.BeforeEach;
+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.jdbc.core.JdbcTemplate;
+import org.springframework.mock.web.MockMultipartFile;
+import org.springframework.security.test.context.support.WithMockUser;
+import org.springframework.test.web.servlet.MockMvc;
+
+import static org.hamcrest.Matchers.containsString;
+import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+/**
+ * Finding 1 회귀 테스트: 업로드 컨트롤러가 확장자·빈 파일 검증 실패를 SeedParseException으로
+ * 던지고, GlobalExceptionHandler가 그것을 500이 아니라 400으로 내려주는지 HTTP 경계에서 확인한다.
+ */
+@AutoConfigureMockMvc
+@WithMockUser(roles = "ADMIN")
+class SeedControllerTest extends AbstractDbTest {
+
+    @Autowired
+    MockMvc mvc;
+
+    @Autowired
+    JdbcTemplate jdbc;
+
+    @BeforeEach
+    void clean() {
+        jdbc.update("delete from organization");
+    }
+
+    @Test
+    void 빈_파일이면_400이다() throws Exception {
+        MockMultipartFile empty = new MockMultipartFile("file", "seed.xlsx",
+                "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", new byte[0]);
+
+        mvc.perform(multipart("/api/seed").file(empty).with(csrf()))
+                .andExpect(status().isBadRequest())
+                .andExpect(jsonPath("$.message").value(containsString("비어")));
+    }
+
+    @Test
+    void 확장자가_xlsx_xlsm이_아니면_400이다() throws Exception {
+        MockMultipartFile txt = new MockMultipartFile("file", "seed.txt",
+                "text/plain", "아무 내용".getBytes());
+
+        mvc.perform(multipart("/api/seed").file(txt).with(csrf()))
+                .andExpect(status().isBadRequest())
+                .andExpect(jsonPath("$.message").value(containsString("xlsx")));
+    }
+
+    @Test
+    void 시트가_없는_엑셀이면_400이다() throws Exception {
+        byte[] wrongSheet = wrongSheetWorkbook();
+        MockMultipartFile xlsx = new MockMultipartFile("file", "seed.xlsx",
+                "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", wrongSheet);
+
+        mvc.perform(multipart("/api/seed").file(xlsx).with(csrf()))
+                .andExpect(status().isBadRequest())
+                .andExpect(jsonPath("$.message").value(containsString(SeedParser.SHEET_NAME)));
+    }
+
+    private byte[] wrongSheetWorkbook() throws Exception {
+        try (var wb = new org.apache.poi.xssf.usermodel.XSSFWorkbook();
+             var out = new java.io.ByteArrayOutputStream()) {
+            wb.createSheet("다른시트");
+            wb.write(out);
+            return out.toByteArray();
+        }
+    }
+}
Add a comment
List