From 9095b1e7a77ececdc224c751103f1cb26afba216 Mon Sep 17 00:00:00 2001 From: tech Date: Fri, 24 Apr 2026 14:08:54 +0900 Subject: [PATCH] =?UTF-8?q?test=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pom.xml | 5 + scripts/start.sh | 1 + .../java/com/owrawww/OwrawwwApplication.java | 2 +- src/main/java/com/owrawww/domain/Inquiry.java | 2 + .../migration/DataMigrationMapper.java | 21 +++ .../migration/DataMigrationRunner.java | 81 ++++++++++ .../com/owrawww/service/InquiryService.java | 2 + src/main/java/com/owrawww/util/AesUtil.java | 28 +++- src/main/resources/application-prod.yml | 3 +- src/main/resources/application-test.yml | 7 +- src/main/resources/application.yml | 2 + .../resources/mapper/DataMigrationMapper.xml | 29 ++++ src/main/resources/mapper/InquiryMapper.xml | 4 +- src/main/resources/schema.sql | 9 ++ .../controller/HomeControllerTest.java | 100 +++++++++++++ .../controller/InquiryControllerTest.java | 138 ++++++++++++++++++ .../com/owrawww/service/BbsServiceTest.java | 103 +++++++++++++ .../owrawww/service/InquiryServiceTest.java | 87 +++++++++++ 18 files changed, 617 insertions(+), 7 deletions(-) create mode 100644 src/main/java/com/owrawww/migration/DataMigrationMapper.java create mode 100644 src/main/java/com/owrawww/migration/DataMigrationRunner.java create mode 100644 src/main/resources/mapper/DataMigrationMapper.xml create mode 100644 src/test/java/com/owrawww/controller/HomeControllerTest.java create mode 100644 src/test/java/com/owrawww/controller/InquiryControllerTest.java create mode 100644 src/test/java/com/owrawww/service/BbsServiceTest.java create mode 100644 src/test/java/com/owrawww/service/InquiryServiceTest.java diff --git a/pom.xml b/pom.xml index 2d3bbda..0967737 100644 --- a/pom.xml +++ b/pom.xml @@ -82,6 +82,11 @@ spring-boot-starter-test test + + org.springframework.boot + spring-boot-starter-webmvc-test + test + diff --git a/scripts/start.sh b/scripts/start.sh index bb2a140..5a41b44 100644 --- a/scripts/start.sh +++ b/scripts/start.sh @@ -24,6 +24,7 @@ mkdir -p "$LOG_DIR" # 시작 echo "[$APP_NAME] 시작 중..." nohup "$JAVA_HOME/bin/java" \ + -DAPP_HASH_SALT="${APP_HASH_SALT}" \ -jar "$JAR" \ --spring.profiles.active=prod \ > /dev/null 2>&1 & diff --git a/src/main/java/com/owrawww/OwrawwwApplication.java b/src/main/java/com/owrawww/OwrawwwApplication.java index 0074f88..7dda3f1 100644 --- a/src/main/java/com/owrawww/OwrawwwApplication.java +++ b/src/main/java/com/owrawww/OwrawwwApplication.java @@ -5,7 +5,7 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; @SpringBootApplication -@MapperScan("com.owrawww.domain.mapper") +@MapperScan({"com.owrawww.domain.mapper", "com.owrawww.migration"}) public class OwrawwwApplication { public static void main(String[] args) { diff --git a/src/main/java/com/owrawww/domain/Inquiry.java b/src/main/java/com/owrawww/domain/Inquiry.java index a37676b..38290a3 100644 --- a/src/main/java/com/owrawww/domain/Inquiry.java +++ b/src/main/java/com/owrawww/domain/Inquiry.java @@ -45,5 +45,7 @@ public class Inquiry { private Integer subGubun; private Integer depth; private String solutionGubun; + private String telHash; // SHA-256(솔트+tel) - 검색/중복체크용 + private String emailHash; // SHA-256(솔트+email) - 검색/중복체크용 } diff --git a/src/main/java/com/owrawww/migration/DataMigrationMapper.java b/src/main/java/com/owrawww/migration/DataMigrationMapper.java new file mode 100644 index 0000000..5a5cebf --- /dev/null +++ b/src/main/java/com/owrawww/migration/DataMigrationMapper.java @@ -0,0 +1,21 @@ +package com.owrawww.migration; + +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.util.List; +import java.util.Map; + +@Mapper +public interface DataMigrationMapper { + + /** 미암호화 레코드 조회 (phone 길이 30자 이하 = 평문) */ + List> selectPlainRecords(); + + /** phone, tel_hash, email, email_hash 일괄 업데이트 */ + int updateEncrypted(@Param("id") Long id, + @Param("phone") String phone, + @Param("telHash") String telHash, + @Param("email") String email, + @Param("emailHash") String emailHash); +} diff --git a/src/main/java/com/owrawww/migration/DataMigrationRunner.java b/src/main/java/com/owrawww/migration/DataMigrationRunner.java new file mode 100644 index 0000000..cdb4f06 --- /dev/null +++ b/src/main/java/com/owrawww/migration/DataMigrationRunner.java @@ -0,0 +1,81 @@ +package com.owrawww.migration; + +import com.owrawww.util.AesUtil; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Map; + +/** + * 기존 평문 phone/email 데이터를 일괄 암호화하는 마이그레이션 Runner. + * + * 실행 방법 (운영 서버): + * java -jar owrawww.jar --spring.profiles.active=prod,migration + * + * 주의사항: + * - 한 번만 실행해야 합니다. 재실행 시 이미 암호화된 데이터는 자동으로 건너뜁니다. + * - 실행 전 DB 백업을 반드시 수행하세요. + * - 마이그레이션 완료 후 이 profile 없이 앱을 재시작하세요. + */ +@Slf4j +@Profile("migration") +@Component +@RequiredArgsConstructor +public class DataMigrationRunner implements ApplicationRunner { + + private final DataMigrationMapper migrationMapper; + private final AesUtil aesUtil; + + @Override + @Transactional + public void run(ApplicationArguments args) { + log.info("=== [마이그레이션 시작] phone/email 암호화 ==="); + + List> records = migrationMapper.selectPlainRecords(); + log.info("암호화 대상 레코드 수: {}건", records.size()); + + if (records.isEmpty()) { + log.info("암호화할 평문 데이터가 없습니다. 이미 완료됐거나 데이터가 없습니다."); + return; + } + + int success = 0; + int skip = 0; + + for (Map row : records) { + Long id = ((Number) row.get("id")).longValue(); + String phone = (String) row.get("phone"); + String email = (String) row.get("email"); + + // null/빈값 방어 + if (phone == null || phone.isBlank() || email == null || email.isBlank()) { + log.warn(" ID={} : phone 또는 email 이 비어있어 건너뜁니다.", id); + skip++; + continue; + } + + try { + String encPhone = aesUtil.encrypt(phone); + String hashPhone = aesUtil.hash(phone); + String encEmail = aesUtil.encrypt(email); + String hashEmail = aesUtil.hash(email); + + migrationMapper.updateEncrypted(id, encPhone, hashPhone, encEmail, hashEmail); + log.info(" ID={} : 암호화 완료", id); + success++; + } catch (Exception e) { + log.error(" ID={} : 암호화 실패 - {}", id, e.getMessage()); + throw new RuntimeException("ID=" + id + " 처리 중 오류 발생. 트랜잭션 롤백.", e); + } + } + + log.info("=== [마이그레이션 완료] 성공: {}건, 건너뜀: {}건 ===", success, skip); + log.info("마이그레이션이 끝났습니다. 'migration' 프로파일 없이 앱을 재시작하세요."); + } +} diff --git a/src/main/java/com/owrawww/service/InquiryService.java b/src/main/java/com/owrawww/service/InquiryService.java index c83fb6f..70c3707 100644 --- a/src/main/java/com/owrawww/service/InquiryService.java +++ b/src/main/java/com/owrawww/service/InquiryService.java @@ -20,7 +20,9 @@ public class InquiryService { savedInquiry.setComment(inquiry.getContent()); savedInquiry.setName(inquiry.getName()); savedInquiry.setTel(aesUtil.encrypt(inquiry.getTel())); + savedInquiry.setTelHash(aesUtil.hash(inquiry.getTel())); savedInquiry.setEmail(aesUtil.encrypt(inquiry.getEmail())); + savedInquiry.setEmailHash(aesUtil.hash(inquiry.getEmail())); savedInquiry.setTopCode(2); savedInquiry.setLeftCode(1); savedInquiry.setSubGubun(1); diff --git a/src/main/java/com/owrawww/util/AesUtil.java b/src/main/java/com/owrawww/util/AesUtil.java index 6931f65..1497b7f 100644 --- a/src/main/java/com/owrawww/util/AesUtil.java +++ b/src/main/java/com/owrawww/util/AesUtil.java @@ -7,8 +7,12 @@ import javax.crypto.Cipher; import javax.crypto.SecretKey; import javax.crypto.spec.GCMParameterSpec; import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; import java.security.SecureRandom; import java.util.Base64; +import java.util.HexFormat; /** * AES-256-GCM 암호화/복호화 유틸리티 @@ -22,13 +26,17 @@ public class AesUtil { private static final int TAG_BIT_LEN = 128; // GCM 인증 태그 크기 private final SecretKey secretKey; + private final String hashSalt; - public AesUtil(@Value("${app.encryption.key}") String base64Key) { + public AesUtil( + @Value("${app.encryption.key}") String base64Key, + @Value("${app.hash.salt}") String hashSalt) { byte[] keyBytes = Base64.getDecoder().decode(base64Key); if (keyBytes.length != 32) { throw new IllegalArgumentException("암호화 키는 Base64 인코딩된 32바이트(256bit)여야 합니다."); } this.secretKey = new SecretKeySpec(keyBytes, "AES"); + this.hashSalt = hashSalt; } /** @@ -75,4 +83,22 @@ public class AesUtil { throw new RuntimeException("복호화 실패", e); } } + + /** + * 평문 → SHA-256(솔트 + 평문) → HEX 문자열 (검색/중복체크용) + * - 단방향: 복호화 불가 + * - 솔트로 레인보우 테이블 공격 방어 + */ + public String hash(String plainText) { + if (plainText == null || plainText.isEmpty()) return plainText; + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] hashBytes = digest.digest( + (hashSalt + plainText).getBytes(StandardCharsets.UTF_8) + ); + return HexFormat.of().formatHex(hashBytes); + } catch (NoSuchAlgorithmException e) { + throw new RuntimeException("SHA-256 알고리즘을 사용할 수 없습니다.", e); + } + } } diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index e4df70a..2c93c25 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -33,4 +33,5 @@ app: path: /home/www/owrainfo/uploads encryption: key: PIYAsB81yr7uETVoM/E7Sz9Sp5kNB46uHyjnfQE3XOs= # 운영 전용 키로 교체 필요 (base64 인코딩된 32바이트) - \ No newline at end of file + hash: + salt: ${APP_HASH_SALT:9C4622A0E11849F94C490FCE1E6114F05BFF98FCEB5B85067F09E35ADD0D3FAB} # 반드시 환경변수로 주입 (서버에서 설정) \ No newline at end of file diff --git a/src/main/resources/application-test.yml b/src/main/resources/application-test.yml index 58398ba..75099bc 100644 --- a/src/main/resources/application-test.yml +++ b/src/main/resources/application-test.yml @@ -21,7 +21,10 @@ logging: com.owrawww: WARN org.springframework.security: WARN -app: +app: upload: path: /home/www/owrainfo/uploads - + encryption: + key: PIYAsB81yr7uETVoM/E7Sz9Sp5kNB46uHyjnfQE3XOs= # 운영 전용 키로 교체 필요 (base64 인코딩된 32바이트) + hash: + salt: ${APP_HASH_SALT:9C4622A0E11849F94C490FCE1E6114F05BFF98FCEB5B85067F09E35ADD0D3FAB} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 41551f6..1fee038 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -32,3 +32,5 @@ app: path: D:/uploads/owrawww/careers # 기본값 (프로파일별로 override) encryption: key: PIYAsB81yr7uETVoM/E7Sz9Sp5kNB46uHyjnfQE3XOs= # AES-256 키 (운영에서는 반드시 별도 키로 교체) + hash: + salt: ${APP_HASH_SALT:3DBD3CD58A81C407AD0D3E10515DAD7544E70911A2E43EF3A07042B102D41716} # 환경변수 우선, 없으면 기본값 diff --git a/src/main/resources/mapper/DataMigrationMapper.xml b/src/main/resources/mapper/DataMigrationMapper.xml new file mode 100644 index 0000000..78d3ce6 --- /dev/null +++ b/src/main/resources/mapper/DataMigrationMapper.xml @@ -0,0 +1,29 @@ + + + + + + + + + + UPDATE application_table + SET phone = #{phone}, + tel_hash = #{telHash}, + email = #{email}, + email_hash = #{emailHash} + WHERE id = #{id} + + + diff --git a/src/main/resources/mapper/InquiryMapper.xml b/src/main/resources/mapper/InquiryMapper.xml index b2cabbd..0f3477b 100644 --- a/src/main/resources/mapper/InquiryMapper.xml +++ b/src/main/resources/mapper/InquiryMapper.xml @@ -5,8 +5,8 @@ - INSERT INTO application_table (code, title, name, type, phone, email, comment, depth, in_date, top_code, left_code, sub_gubun, solution_gubun) - VALUES (#{code}, #{title}, #{name}, #{type}, #{tel}, #{email}, #{comment}, #{depth}, now(), #{topCode}, #{leftCode}, #{subGubun}, #{solutionGubun}) + INSERT INTO application_table (code, title, name, type, phone, tel_hash, email, email_hash, comment, depth, in_date, top_code, left_code, sub_gubun, solution_gubun) + VALUES (#{code}, #{title}, #{name}, #{type}, #{tel}, #{telHash}, #{email}, #{emailHash}, #{comment}, #{depth}, now(), #{topCode}, #{leftCode}, #{subGubun}, #{solutionGubun}) diff --git a/src/main/resources/schema.sql b/src/main/resources/schema.sql index b126cbd..a9ddaae 100644 --- a/src/main/resources/schema.sql +++ b/src/main/resources/schema.sql @@ -37,3 +37,12 @@ CREATE TABLE IF NOT EXISTS inquiry ( -- BCrypt 해시값 사전 생성 필요 시 아래 INSERT 사용 -- INSERT INTO users (username, password, role, email, enabled) -- VALUES ('admin', '$2a$10$...', 'ROLE_ADMIN', 'admin@owrawww.com', 1); + +-- ===================================================================== +-- [마이그레이션] tel_hash / email_hash 컬럼 추가 (솔트 해시 검색용) +-- 운영 DB에 아래 ALTER TABLE 를 직접 실행해야 합니다. +-- ===================================================================== +-- ALTER TABLE application_table ADD COLUMN tel_hash VARCHAR(64) AFTER phone; +-- ALTER TABLE application_table ADD COLUMN email_hash VARCHAR(64) AFTER email; +-- CREATE INDEX idx_app_tel_hash ON application_table(tel_hash); +-- CREATE INDEX idx_app_email_hash ON application_table(email_hash); diff --git a/src/test/java/com/owrawww/controller/HomeControllerTest.java b/src/test/java/com/owrawww/controller/HomeControllerTest.java new file mode 100644 index 0000000..00e6212 --- /dev/null +++ b/src/test/java/com/owrawww/controller/HomeControllerTest.java @@ -0,0 +1,100 @@ +package com.owrawww.controller; + +import com.owrawww.domain.Bbs; +import com.owrawww.service.BbsService; +import org.apache.ibatis.mapping.Environment; +import org.apache.ibatis.session.SqlSessionFactory; +import org.apache.ibatis.transaction.TransactionFactory; +import org.mybatis.spring.transaction.SpringManagedTransactionFactory; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.mybatis.spring.boot.autoconfigure.MybatisAutoConfiguration; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +import java.util.List; + +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.BDDMockito.given; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@Import(HomeControllerTest.MybatisTestConfig.class) +@WebMvcTest(value = HomeController.class, excludeAutoConfiguration = MybatisAutoConfiguration.class) +class HomeControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockitoBean + private BbsService bbsService; + + @Test + @DisplayName("GET / - 메인 페이지 정상 응답") + void index_returnsIndexView() throws Exception { + Bbs bbs = new Bbs(); + bbs.setId(1L); + bbs.setTitle("공지사항"); + + given(bbsService.getPreview(anyInt(), anyInt(), anyInt(), anyInt())) + .willReturn(List.of(bbs)); + + mockMvc.perform(get("/")) + .andExpect(status().isOk()) + .andExpect(view().name("index")) + .andExpect(model().attributeExists("progressList")) + .andExpect(model().attributeExists("pressList")); + } + + @Test + @DisplayName("GET /sitemap - 사이트맵 뷰 반환") + void sitemap_returnsSitemapView() throws Exception { + mockMvc.perform(get("/sitemap")) + .andExpect(status().isOk()) + .andExpect(view().name("sitemap")); + } + + @Test + @DisplayName("GET /company/intro - 회사소개 뷰 반환") + void companyIntro_returnsView() throws Exception { + mockMvc.perform(get("/company/intro")) + .andExpect(status().isOk()) + .andExpect(view().name("sub01_01")); + } + + @Test + @DisplayName("GET /business/intro - 사업소개 뷰 반환") + void businessIntro_returnsView() throws Exception { + mockMvc.perform(get("/business/intro")) + .andExpect(status().isOk()) + .andExpect(view().name("sub02_01")); + } + + @Test + @DisplayName("GET /company/map - 오시는길 뷰 반환") + void companyMap_returnsView() throws Exception { + mockMvc.perform(get("/company/map")) + .andExpect(status().isOk()) + .andExpect(view().name("sub01_05")); + } + + @TestConfiguration + static class MybatisTestConfig { + @Bean + public SqlSessionFactory sqlSessionFactory() throws Exception { + TransactionFactory tf = new SpringManagedTransactionFactory(); + javax.sql.DataSource ds = Mockito.mock(javax.sql.DataSource.class); + Environment env = new Environment("test", tf, ds); + org.apache.ibatis.session.Configuration conf = new org.apache.ibatis.session.Configuration(env); + SqlSessionFactory factory = Mockito.mock(SqlSessionFactory.class); + Mockito.lenient().when(factory.getConfiguration()).thenReturn(conf); + return factory; + } + } +} diff --git a/src/test/java/com/owrawww/controller/InquiryControllerTest.java b/src/test/java/com/owrawww/controller/InquiryControllerTest.java new file mode 100644 index 0000000..7ba172d --- /dev/null +++ b/src/test/java/com/owrawww/controller/InquiryControllerTest.java @@ -0,0 +1,138 @@ +package com.owrawww.controller; + +import com.owrawww.service.InquiryService; +import org.apache.ibatis.mapping.Environment; +import org.apache.ibatis.session.SqlSessionFactory; +import org.apache.ibatis.transaction.TransactionFactory; +import org.mybatis.spring.transaction.SpringManagedTransactionFactory; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.mybatis.spring.boot.autoconfigure.MybatisAutoConfiguration; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; +import org.springframework.http.MediaType; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@Import(InquiryControllerTest.MybatisTestConfig.class) +@WebMvcTest(value = InquiryController.class, excludeAutoConfiguration = MybatisAutoConfiguration.class) +class InquiryControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockitoBean + private InquiryService inquiryService; + + @Test + @DisplayName("GET /sub04_02 - 문의 폼 페이지 정상 응답") + void inquiryForm_returnsView() throws Exception { + mockMvc.perform(get("/sub04_02")) + .andExpect(status().isOk()) + .andExpect(view().name("sub04_02")); + } + + @Test + @DisplayName("POST /sub04_02 - 유효한 입력 시 성공 응답") + void inquirySubmit_validInput_returnsSuccess() throws Exception { + given(inquiryService.submit(any())).willReturn(true); + + mockMvc.perform(post("/sub04_02") + .contentType(MediaType.APPLICATION_FORM_URLENCODED) + .param("name", "홍길동") + .param("email", "hong@example.com") + .param("tel", "010-1234-5678") + .param("dept", "개발팀") + .param("title", "도입 문의") + .param("content", "서비스 도입 관련 문의드립니다.") + .param("solutionGubun", "PG")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)); + } + + @Test + @DisplayName("POST /sub04_02 - 이름 누락 시 400 Bad Request") + void inquirySubmit_missingName_returnsBadRequest() throws Exception { + mockMvc.perform(post("/sub04_02") + .contentType(MediaType.APPLICATION_FORM_URLENCODED) + .param("email", "hong@example.com") + .param("tel", "010-1234-5678") + .param("dept", "개발팀") + .param("title", "도입 문의") + .param("content", "서비스 도입 관련 문의드립니다.")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.success").value(false)); + } + + @Test + @DisplayName("POST /sub04_02 - 이메일 형식 오류 시 400 Bad Request") + void inquirySubmit_invalidEmail_returnsBadRequest() throws Exception { + mockMvc.perform(post("/sub04_02") + .contentType(MediaType.APPLICATION_FORM_URLENCODED) + .param("name", "홍길동") + .param("email", "not-an-email") + .param("tel", "010-1234-5678") + .param("dept", "개발팀") + .param("title", "도입 문의") + .param("content", "내용")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.success").value(false)); + } + + @Test + @DisplayName("POST /sub04_02 - 연락처 형식 오류 시 400 Bad Request") + void inquirySubmit_invalidTel_returnsBadRequest() throws Exception { + mockMvc.perform(post("/sub04_02") + .contentType(MediaType.APPLICATION_FORM_URLENCODED) + .param("name", "홍길동") + .param("email", "hong@example.com") + .param("tel", "invalid!") + .param("dept", "개발팀") + .param("title", "도입 문의") + .param("content", "내용")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.success").value(false)); + } + + @Test + @DisplayName("POST /sub04_02 - 서비스 저장 실패 시 success false 반환") + void inquirySubmit_serviceFailure_returnsFailure() throws Exception { + given(inquiryService.submit(any())).willReturn(false); + + mockMvc.perform(post("/sub04_02") + .contentType(MediaType.APPLICATION_FORM_URLENCODED) + .param("name", "홍길동") + .param("email", "hong@example.com") + .param("tel", "010-1234-5678") + .param("dept", "개발팀") + .param("title", "도입 문의") + .param("content", "서비스 도입 관련 문의드립니다.") + .param("solutionGubun", "PG")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.message").exists()); + } + + @TestConfiguration + static class MybatisTestConfig { + @Bean + public SqlSessionFactory sqlSessionFactory() throws Exception { + TransactionFactory tf = new SpringManagedTransactionFactory(); + javax.sql.DataSource ds = Mockito.mock(javax.sql.DataSource.class); + Environment env = new Environment("test", tf, ds); + org.apache.ibatis.session.Configuration conf = new org.apache.ibatis.session.Configuration(env); + SqlSessionFactory factory = Mockito.mock(SqlSessionFactory.class); + Mockito.lenient().when(factory.getConfiguration()).thenReturn(conf); + return factory; + } + } +} diff --git a/src/test/java/com/owrawww/service/BbsServiceTest.java b/src/test/java/com/owrawww/service/BbsServiceTest.java new file mode 100644 index 0000000..973cdd1 --- /dev/null +++ b/src/test/java/com/owrawww/service/BbsServiceTest.java @@ -0,0 +1,103 @@ +package com.owrawww.service; + +import com.owrawww.domain.Bbs; +import com.owrawww.domain.mapper.BbsMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +class BbsServiceTest { + + @Mock + private BbsMapper bbsMapper; + + @InjectMocks + private BbsService bbsService; + + private Bbs sampleBbs; + + @BeforeEach + void setUp() { + sampleBbs = new Bbs(); + sampleBbs.setId(1L); + sampleBbs.setTitle("테스트 제목"); + sampleBbs.setName("작성자"); + sampleBbs.setBody("내용입니다."); + sampleBbs.setViewCnt(0); + } + + @Test + @DisplayName("getList - 정상적으로 목록과 페이징 정보를 반환한다") + void getList_returnsListAndPagingInfo() { + given(bbsMapper.selectList(any())).willReturn(List.of(sampleBbs, sampleBbs)); + given(bbsMapper.selectCount(any())).willReturn(2); + + Map result = bbsService.getList(1, "all", "", 1, 1, 1); + + assertThat(result.get("list")).isEqualTo(List.of(sampleBbs, sampleBbs)); + assertThat(result.get("totalCount")).isEqualTo(2); + assertThat(result.get("currentPage")).isEqualTo(1); + assertThat(result.get("totalPages")).isEqualTo(1); + } + + @Test + @DisplayName("getList - 페이지 번호가 0 이하이면 1로 보정된다") + void getList_pageUnderOne_normalizedToOne() { + given(bbsMapper.selectList(any())).willReturn(List.of()); + given(bbsMapper.selectCount(any())).willReturn(0); + + Map result = bbsService.getList(0, "all", "", 1, 1, 1); + + assertThat(result.get("currentPage")).isEqualTo(1); + } + + @Test + @DisplayName("getList - totalCount가 0이면 totalPages는 1이다") + void getList_emptyResult_totalPagesIsOne() { + given(bbsMapper.selectList(any())).willReturn(List.of()); + given(bbsMapper.selectCount(any())).willReturn(0); + + Map result = bbsService.getList(1, "all", "", 1, 1, 1); + + assertThat(result.get("totalPages")).isEqualTo(1); + } + + @Test + @DisplayName("getDetail - 조회수 증가 후 게시글과 이전/다음 정보를 반환한다") + void getDetail_incrementsViewCntAndReturnsDetail() { + given(bbsMapper.selectById(any())).willReturn(sampleBbs); + given(bbsMapper.selectPrev(any())).willReturn(null); + given(bbsMapper.selectNext(any())).willReturn(null); + + Map result = bbsService.getDetail(1L, 1, 1, 1, 1, "all", ""); + + verify(bbsMapper).incrementViewCnt(1L); + assertThat(result.get("bbs")).isEqualTo(sampleBbs); + assertThat(result.get("prev")).isNull(); + assertThat(result.get("next")).isNull(); + } + + @Test + @DisplayName("getPreview - limit 개수만큼 미리보기 목록을 반환한다") + void getPreview_returnsLimitedList() { + given(bbsMapper.selectPreview(any())).willReturn(List.of(sampleBbs)); + + List result = bbsService.getPreview(1, 2, 1, 5); + + assertThat(result).hasSize(1); + assertThat(result.get(0).getTitle()).isEqualTo("테스트 제목"); + } +} diff --git a/src/test/java/com/owrawww/service/InquiryServiceTest.java b/src/test/java/com/owrawww/service/InquiryServiceTest.java new file mode 100644 index 0000000..0ddb0e1 --- /dev/null +++ b/src/test/java/com/owrawww/service/InquiryServiceTest.java @@ -0,0 +1,87 @@ +package com.owrawww.service; + +import com.owrawww.domain.Inquiry; +import com.owrawww.domain.mapper.InquiryMapper; +import com.owrawww.util.AesUtil; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +class InquiryServiceTest { + + @Mock + private InquiryMapper inquiryMapper; + + @Mock + private AesUtil aesUtil; + + @InjectMocks + private InquiryService inquiryService; + + private Inquiry inquiry; + + @BeforeEach + void setUp() { + inquiry = new Inquiry(); + inquiry.setName("홍길동"); + inquiry.setEmail("hong@example.com"); + inquiry.setTel("010-1234-5678"); + inquiry.setTitle("도입 문의"); + inquiry.setContent("서비스 도입 관련 문의드립니다."); + inquiry.setSolutionGubun("PG"); + } + + @Test + @DisplayName("submit - 정상 입력 시 저장에 성공하고 true를 반환한다") + void submit_success() { + given(aesUtil.encrypt(inquiry.getTel())).willReturn("ENCRYPTED_TEL"); + given(aesUtil.encrypt(inquiry.getEmail())).willReturn("ENCRYPTED_EMAIL"); + given(aesUtil.hash(inquiry.getTel())).willReturn("HASH_TEL"); + given(aesUtil.hash(inquiry.getEmail())).willReturn("HASH_EMAIL"); + given(inquiryMapper.insert(any(Inquiry.class))).willReturn(1); + + boolean result = inquiryService.submit(inquiry); + + assertThat(result).isTrue(); + verify(inquiryMapper).insert(any(Inquiry.class)); + } + + @Test + @DisplayName("submit - DB 저장 실패 시 false를 반환한다") + void submit_dbFails_returnsFalse() { + given(aesUtil.encrypt(any())).willReturn("ENCRYPTED"); + given(aesUtil.hash(any())).willReturn("HASHED"); + given(inquiryMapper.insert(any(Inquiry.class))).willReturn(0); + + boolean result = inquiryService.submit(inquiry); + + assertThat(result).isFalse(); + } + + @Test + @DisplayName("submit - 전화번호와 이메일이 암호화 및 해시화되어 저장된다") + void submit_encryptsAndHashesSensitiveFields() { + given(aesUtil.encrypt("010-1234-5678")).willReturn("ENC_TEL"); + given(aesUtil.encrypt("hong@example.com")).willReturn("ENC_EMAIL"); + given(aesUtil.hash("010-1234-5678")).willReturn("HASH_TEL"); + given(aesUtil.hash("hong@example.com")).willReturn("HASH_EMAIL"); + given(inquiryMapper.insert(any(Inquiry.class))).willReturn(1); + + inquiryService.submit(inquiry); + + verify(aesUtil).encrypt("010-1234-5678"); + verify(aesUtil).encrypt("hong@example.com"); + verify(aesUtil).hash("010-1234-5678"); + verify(aesUtil).hash("hong@example.com"); + } +}