From 6eedac51f7c1869f642c3e69bf406bba96baff05 Mon Sep 17 00:00:00 2001 From: curry772 Date: Wed, 20 May 2026 16:33:21 +0900 Subject: [PATCH] =?UTF-8?q?=EB=A7=88=EC=8A=A4=ED=82=B9=20=EC=9C=A0?= =?UTF-8?q?=ED=8B=B8=EB=A6=AC=ED=8B=B0=20=EC=B6=94=EA=B0=80=20(MaskingUtil?= =?UTF-8?q?s=20+=20=EB=8B=A8=EC=9C=84=ED=85=8C=EC=8A=A4=ED=8A=B8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 보안아키텍처 마스킹 정책에 따라 고유식별정보·금융식별정보·인증정보·신상정보·온라인정보 - 카테고리별 마스킹 처리 유틸 및 JUnit 5 단위테스트(3단계 번호 체계) 신규 작성 --- .../eactive/eai/common/util/MaskingUtils.java | 440 +++++++++++++ .../eai/common/util/MaskingUtilsTest.java | 581 ++++++++++++++++++ 2 files changed, 1021 insertions(+) create mode 100644 src/main/java/com/eactive/eai/common/util/MaskingUtils.java create mode 100644 src/test/java/com/eactive/eai/common/util/MaskingUtilsTest.java diff --git a/src/main/java/com/eactive/eai/common/util/MaskingUtils.java b/src/main/java/com/eactive/eai/common/util/MaskingUtils.java new file mode 100644 index 0000000..d114843 --- /dev/null +++ b/src/main/java/com/eactive/eai/common/util/MaskingUtils.java @@ -0,0 +1,440 @@ +package com.eactive.eai.common.util; + +import java.util.Arrays; + +/** + * 보안아키텍처 마스킹 정책 구현 유틸리티 + * + *
+ * [고유식별정보]
+ *   주민등록번호  123456-1234567  →  123456-1******
+ *   운전면허번호  제주-22-300001-50  →  제주-22-3*****-**
+ *   여권번호     M12345678  →  M1234****
+ *   외국인등록번호 123456-5234567  →  123456-5******
+ *
+ * [금융식별정보]
+ *   계좌번호     99-912345-1  →  99-9*-*****1
+ *   카드번호     4518-4212-3456-1234  →  4518-42**-****-1234
+ *   유효기간     12/25  →  **/**
+ *   CVV         123  →  ***
+ *
+ * [인증정보]
+ *   비밀번호     password  →  ********
+ *
+ * [신상정보]
+ *   전화번호     064-1234-5678  →  064-12**-56**
+ *   이메일       aajjbacc@shinhan.com  →  **jjba**@shinhan.com
+ *   한글 성명    홍길동  →  홍*동
+ *   영문 성명    GILDONG HONG  →  ******* HONG
+ *   지번주소     제주특별자치도 제주시 연동 123번지, 101동 202호  →  제주특별자치도 제주시 연동 ***번지, **** ****
+ *   도로명주소   제주특별자치도 제주시 은남2길 5, 101동 202호  →  제주특별자치도 제주시 은남**길 ***, **** ****
+ *
+ * [온라인 정보]
+ *   IPv4        100.200.123.45  →  100.200.***.45
+ *   IPv6        21DA:00D3:0000:2F3B:02AA:00FF:FE28:9C5A  →  21DA:00D3:0000:2F3B:02AA:00FF:FE28:****
+ * 
+ */ +public final class MaskingUtils { + + private static final char MASK = '*'; + + private MaskingUtils() {} + + // ========================================================================= + // 고유식별정보 + // ========================================================================= + + /** + * 주민등록번호 마스킹: 뒷 6자리 마스킹 + *
123456-1234567 → 123456-1******
+ */ + public static String maskResidentNumber(String value) { + if (value == null || value.isEmpty()) return value; + int idx = value.indexOf('-'); + if (idx < 0) { + return maskTailChars(value, 6); + } + // 하이픈 + 성별 1자리까지 노출, 이후 6자리 마스킹 + String front = value.substring(0, Math.min(idx + 2, value.length())); + return front + stars(6); + } + + /** + * 운전면허번호 마스킹: 뒤에서부터 숫자 7자리 마스킹 + *
제주-22-300001-50 → 제주-22-3*****-**
+ */ + public static String maskDriverLicense(String value) { + if (value == null || value.isEmpty()) return value; + char[] chars = value.toCharArray(); + int digitCount = 0; + for (int i = chars.length - 1; i >= 0 && digitCount < 7; i--) { + if (Character.isDigit(chars[i])) { + chars[i] = MASK; + digitCount++; + } + } + return new String(chars); + } + + /** + * 여권번호 마스킹: 뒤에서부터 4자리 마스킹 + *
M12345678 → M1234****
+ */ + public static String maskPassport(String value) { + if (value == null || value.isEmpty()) return value; + return maskTailChars(value, 4); + } + + /** + * 외국인등록번호 마스킹: 뒷 6자리 마스킹 (주민등록번호와 동일) + *
123456-5234567 → 123456-5******
+ */ + public static String maskForeignerNumber(String value) { + return maskResidentNumber(value); + } + + // ========================================================================= + // 금융식별정보 + // ========================================================================= + + /** + * 계좌번호 마스킹: 첫 3자리, 마지막 1자리만 노출 + *
99-912345-1 → 99-9*-*****1
+ */ + public static String maskAccountNumber(String value) { + if (value == null || value.isEmpty()) return value; + String digitsOnly = value.replaceAll("[^0-9]", ""); + int totalDigits = digitsOnly.length(); + if (totalDigits <= 4) return value; + + char[] chars = value.toCharArray(); + int digitIdx = 0; + for (int i = 0; i < chars.length; i++) { + if (Character.isDigit(chars[i])) { + digitIdx++; + // 4번째 자리부터 마지막 자리 직전까지 마스킹 + if (digitIdx > 3 && digitIdx < totalDigits) { + chars[i] = MASK; + } + } + } + return new String(chars); + } + + /** + * 카드번호 마스킹: PCI-DSS 기준 7~12번째 숫자 자리 마스킹 + *
4518-4212-3456-1234 → 4518-42**-****-1234
+ */ + public static String maskCardNumber(String value) { + if (value == null || value.isEmpty()) return value; + char[] chars = value.toCharArray(); + int digitIdx = 0; + for (int i = 0; i < chars.length; i++) { + if (Character.isDigit(chars[i])) { + digitIdx++; + if (digitIdx >= 7 && digitIdx <= 12) { + chars[i] = MASK; + } + } + } + return new String(chars); + } + + /** + * 카드 유효기간 마스킹: 월/년 모두 마스킹 + *
12/25 → **/**
+ */ + public static String maskCardExpiry(String value) { + if (value == null || value.isEmpty()) return value; + return value.replaceAll("[0-9]", String.valueOf(MASK)); + } + + /** + * CVV 마스킹: 3자리 전체 마스킹 + *
123 → ***
+ */ + public static String maskCvv(String value) { + if (value == null || value.isEmpty()) return value; + return stars(value.length()); + } + + // ========================================================================= + // 인증정보 + // ========================================================================= + + /** + * 비밀번호 마스킹: 전체 자리 마스킹 + *
password1234 → ************
+ */ + public static String maskPassword(String value) { + if (value == null || value.isEmpty()) return value; + return stars(value.length()); + } + + // ========================================================================= + // 신상정보 + // ========================================================================= + + /** + * 전화번호/FAX 마스킹: 가운데 뒷 2자리, 뒷자리 뒷 2자리 마스킹 + * 하이픈 없는 형식은 자릿수·접두사 기반으로 세그먼트를 분리한 뒤 마스킹하고 하이픈 없이 반환 + *
+     *   064-1234-5678  → 064-12**-56**
+     *   06412345678    → 0641234**56**
+     *   01012345678    → 01012**56**
+     * 
+ */ + public static String maskPhoneNumber(String value) { + if (value == null || value.isEmpty()) return value; + boolean hasHyphen = value.contains("-"); + String normalized = hasHyphen ? value : normalizePhoneNumber(value); + String[] parts = normalized.split("-"); + if (parts.length == 3) { + String m0 = parts[0]; + String m1 = maskSegmentTail(parts[1], 2); + String m2 = maskSegmentTail(parts[2], 2); + return hasHyphen ? m0 + "-" + m1 + "-" + m2 : m0 + m1 + m2; + } + return value; + } + + /** + * 한국 전화번호(숫자만) → XXX-XXXX-XXXX 형식으로 정규화 + *
+     *   02 + 7자리  → 02-XXX-XXXX
+     *   02 + 8자리  → 02-XXXX-XXXX
+     *   0XX + 7자리 → 0XX-XXX-XXXX
+     *   0XX + 8자리 → 0XX-XXXX-XXXX
+     * 
+ */ + private static String normalizePhoneNumber(String digits) { + int len = digits.length(); + if (digits.startsWith("02")) { + if (len == 9) return digits.substring(0, 2) + "-" + digits.substring(2, 5) + "-" + digits.substring(5); + if (len == 10) return digits.substring(0, 2) + "-" + digits.substring(2, 6) + "-" + digits.substring(6); + } else if (digits.startsWith("0")) { + if (len == 10) return digits.substring(0, 3) + "-" + digits.substring(3, 6) + "-" + digits.substring(6); + if (len == 11) return digits.substring(0, 3) + "-" + digits.substring(3, 7) + "-" + digits.substring(7); + } + return digits; + } + + /** + * 휴대폰 번호 마스킹: 가운데 뒷 2자리, 뒷자리 뒷 2자리 마스킹 + *
010-1234-5678 → 010-12**-56**
+ */ + public static String maskMobileNumber(String value) { + return maskPhoneNumber(value); + } + + /** + * 이메일 마스킹: 앞 2자리와 @ 앞 2자리 마스킹 + *
aajjbacc@shinhan.com → **jjba**@shinhan.com
+ */ + public static String maskEmail(String value) { + if (value == null || value.isEmpty() || !value.contains("@")) return value; + int atIdx = value.indexOf('@'); + String local = value.substring(0, atIdx); + String domain = value.substring(atIdx); + if (local.length() <= 4) { + return stars(local.length()) + domain; + } + return stars(2) + local.substring(2, local.length() - 2) + stars(2) + domain; + } + + /** + * 한글/한자 성명 마스킹 + *
+     *   2자: 뒤 1자리 마스킹   홍동 → 홍*
+     *   3자: 가운데 1자리 마스킹  홍길동 → 홍*동
+     *   4자 이상: 앞뒤 1자 제외한 가운데 전체 마스킹  홍길순동 → 홍**동
+     * 
+ */ + public static String maskKoreanName(String value) { + if (value == null || value.isEmpty()) return value; + int len = value.length(); + if (len == 1) return value; + if (len == 2) { + return String.valueOf(value.charAt(0)) + MASK; + } + if (len == 3) { + return String.valueOf(value.charAt(0)) + MASK + value.charAt(2); + } + // 4자 이상: 첫 자 + 가운데 전체 마스킹 + 마지막 자 + return String.valueOf(value.charAt(0)) + stars(len - 2) + value.charAt(len - 1); + } + + /** + * 영문 성명 마스킹: 성(last name)만 노출, 이름(first name)은 마스킹 + *
GILDONG HONG → ******* HONG
+ * (마지막 공백 구분 토큰이 성(last name)으로 간주) + */ + public static String maskEnglishName(String value) { + if (value == null || value.isEmpty()) return value; + String[] parts = value.trim().split("\\s+"); + if (parts.length == 1) return value; + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < parts.length - 1; i++) { + if (i > 0) sb.append(' '); + sb.append(stars(parts[i].length())); + } + sb.append(' ').append(parts[parts.length - 1]); + return sb.toString(); + } + + /** + * 지번주소 마스킹: 번지 이하 주소 마스킹 (String.length() 보존) + *
+     *   제주특별자치도 제주시 연동 123번지, 101동 202호  →  제주특별자치도 제주시 연동 ***번지, **** ****
+     * 
+ */ + public static String maskJibunAddress(String value) { + if (value == null || value.isEmpty()) return value; + // 번지 앞 숫자 자릿수 보존 마스킹 + String result = maskDigitsBefore(value, "번지"); + // 쉼표 이후 상세주소 마스킹 (공백 유지, 자릿수 보존) + int commaIdx = result.indexOf(','); + if (commaIdx >= 0) { + String after = result.substring(commaIdx + 1); + result = result.substring(0, commaIdx + 1) + after.replaceAll("[^\\s]", String.valueOf(MASK)); + } + return result; + } + + /** + * 도로명주소 마스킹: 도로명 번호 및 번지 이하 마스킹 (String.length() 보존) + *
+     *   제주특별자치도 제주시 은남2길 5, 101동 202호  →  제주특별자치도 제주시 은남*길 *, **** ****
+     * 
+ */ + public static String maskRoadAddress(String value) { + if (value == null || value.isEmpty()) return value; + // 도로명 번호 자릿수 보존 마스킹 (긴 suffix 먼저 처리) + String result = maskDigitsBefore(value, "대로"); + result = maskDigitsBefore(result, "번길"); + result = maskDigitsBefore(result, "길"); + result = maskDigitsBefore(result, "로"); + // 도로명 뒤 집 번호 자릿수 보존 마스킹 + result = maskDigitsAfter(result, "대로"); + result = maskDigitsAfter(result, "번길"); + result = maskDigitsAfter(result, "길"); + result = maskDigitsAfter(result, "로"); + // 쉼표 이후 상세주소 마스킹 (공백 유지, 자릿수 보존) + int commaIdx = result.indexOf(','); + if (commaIdx >= 0) { + String after = result.substring(commaIdx + 1); + result = result.substring(0, commaIdx + 1) + after.replaceAll("[^\\s]", String.valueOf(MASK)); + } + return result; + } + + // ========================================================================= + // 온라인 정보 + // ========================================================================= + + /** + * IPv4 마스킹: 3번째 옥텟(17~24 비트) 마스킹 + *
100.200.123.45 → 100.200.***.45
+ */ + public static String maskIpv4(String value) { + if (value == null || value.isEmpty()) return value; + String[] parts = value.split("\\.", -1); + if (parts.length != 4) return value; + return parts[0] + "." + parts[1] + "." + stars(3) + "." + parts[3]; + } + + /** + * IPv6 마스킹: 마지막 그룹(113~128 비트) 마스킹 + *
21DA:00D3:0000:2F3B:02AA:00FF:FE28:9C5A → 21DA:00D3:0000:2F3B:02AA:00FF:FE28:****
+ */ + public static String maskIpv6(String value) { + if (value == null || value.isEmpty()) return value; + int lastColon = value.lastIndexOf(':'); + if (lastColon < 0) return value; + return value.substring(0, lastColon + 1) + stars(4); + } + + /** + * IP 주소 마스킹: IPv4/IPv6 자동 판별, 쉼표 구분 다중 IP 지원 + *
+     *   100.200.123.45  → 100.200.***.45
+     *   21DA:...FE28:9C5A  → 21DA:...FE28:****
+     *   192.168.1.1,10.0.0.1  → 192.168.***.1,10.0.***.1
+     * 
+ */ + public static String maskIpAddress(String value) { + if (value == null || value.isEmpty()) return value; + String[] ips = value.split(","); + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < ips.length; i++) { + if (i > 0) sb.append(','); + String ip = ips[i].trim(); + if (ip.contains(":")) { + sb.append(maskIpv6(ip)); + } else { + sb.append(maskIpv4(ip)); + } + } + return sb.toString(); + } + + // ========================================================================= + // private helpers + // ========================================================================= + + private static String stars(int count) { + if (count <= 0) return ""; + char[] arr = new char[count]; + Arrays.fill(arr, MASK); + return new String(arr); + } + + private static String maskTailChars(String value, int count) { + if (value.length() <= count) return stars(value.length()); + return value.substring(0, value.length() - count) + stars(count); + } + + private static String maskSegmentTail(String segment, int count) { + if (segment.length() <= count) return stars(segment.length()); + return segment.substring(0, segment.length() - count) + stars(count); + } + + /** keyword 바로 앞에 연속된 숫자를 동일 자릿수의 '*'로 마스킹 */ + private static String maskDigitsBefore(String value, String keyword) { + StringBuilder sb = new StringBuilder(value); + int from = 0; + while (true) { + int kIdx = sb.indexOf(keyword, from); + if (kIdx < 0) break; + int end = kIdx; + int start = end; + while (start > 0 && Character.isDigit(sb.charAt(start - 1))) { + start--; + } + for (int i = start; i < end; i++) { + sb.setCharAt(i, MASK); + } + from = kIdx + keyword.length(); + } + return sb.toString(); + } + + /** keyword 바로 뒤 공백 이후의 연속된 숫자를 동일 자릿수의 '*'로 마스킹 */ + private static String maskDigitsAfter(String value, String keyword) { + StringBuilder sb = new StringBuilder(value); + int from = 0; + while (true) { + int kIdx = sb.indexOf(keyword, from); + if (kIdx < 0) break; + int pos = kIdx + keyword.length(); + while (pos < sb.length() && sb.charAt(pos) == ' ') pos++; + int start = pos; + while (pos < sb.length() && Character.isDigit(sb.charAt(pos))) pos++; + for (int i = start; i < pos; i++) { + sb.setCharAt(i, MASK); + } + from = kIdx + keyword.length(); + } + return sb.toString(); + } +} diff --git a/src/test/java/com/eactive/eai/common/util/MaskingUtilsTest.java b/src/test/java/com/eactive/eai/common/util/MaskingUtilsTest.java new file mode 100644 index 0000000..6753e89 --- /dev/null +++ b/src/test/java/com/eactive/eai/common/util/MaskingUtilsTest.java @@ -0,0 +1,581 @@ +package com.eactive.eai.common.util; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.MethodOrderer; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestMethodOrder; + +@TestMethodOrder(MethodOrderer.DisplayName.class) +class MaskingUtilsTest { + + // ========================================================================= + // 1. 고유식별정보 + // ========================================================================= + + // --- 1-1. 주민등록번호 --- + + @Test + @DisplayName("1-1-1. 주민등록번호 — 하이픈 포함 정상 형식") + void testResidentNumber_withHyphen() { + String input = "123456-1234567"; + String result = MaskingUtils.maskResidentNumber(input); + assertEquals("123456-1******", result); + assertEquals(input.length(), result.length()); + } + + @Test + @DisplayName("1-1-2. 주민등록번호 — 하이픈 없는 형식 (뒤 6자리 마스킹)") + void testResidentNumber_withoutHyphen() { + String input = "0701011234567"; + String result = MaskingUtils.maskResidentNumber(input); + assertEquals("0701011******", result); + assertEquals(input.length(), result.length()); + } + + @Test + @DisplayName("1-1-3. 주민등록번호 — null/빈 문자열 방어") + void testResidentNumber_nullAndEmpty() { + assertNull(MaskingUtils.maskResidentNumber(null)); + assertEquals("", MaskingUtils.maskResidentNumber("")); + } + + // --- 1-2. 운전면허번호 --- + + @Test + @DisplayName("1-2-1. 운전면허번호 — 숫자 7자리 역방향 마스킹") + void testDriverLicense_normal() { + String input = "제주-22-300001-50"; + String result = MaskingUtils.maskDriverLicense(input); + assertEquals("제주-22-3*****-**", result); + assertEquals(input.length(), result.length()); + } + + @Test + @DisplayName("1-2-2. 운전면허번호 — null/빈 문자열 방어") + void testDriverLicense_nullAndEmpty() { + assertNull(MaskingUtils.maskDriverLicense(null)); + assertEquals("", MaskingUtils.maskDriverLicense("")); + } + + // --- 1-3. 여권번호 --- + + @Test + @DisplayName("1-3-1. 여권번호 — 구여권 형식") + void testPassport_old() { + String input = "M12345678"; + String result = MaskingUtils.maskPassport(input); + assertEquals("M1234****", result); + assertEquals(input.length(), result.length()); + } + + @Test + @DisplayName("1-3-2. 여권번호 — 신여권 형식 (영문+숫자 혼합)") + void testPassport_new() { + String input = "M123A5678"; + String result = MaskingUtils.maskPassport(input); + assertEquals("M123A****", result); + assertEquals(input.length(), result.length()); + } + + @Test + @DisplayName("1-3-3. 여권번호 — null/빈 문자열 방어") + void testPassport_nullAndEmpty() { + assertNull(MaskingUtils.maskPassport(null)); + assertEquals("", MaskingUtils.maskPassport("")); + } + + // --- 1-4. 외국인등록번호 --- + + @Test + @DisplayName("1-4-1. 외국인등록번호 — 주민등록번호와 동일 방식") + void testForeignerNumber_normal() { + String input = "123456-5234567"; + String result = MaskingUtils.maskForeignerNumber(input); + assertEquals("123456-5******", result); + assertEquals(input.length(), result.length()); + } + + // ========================================================================= + // 2. 금융식별정보 + // ========================================================================= + + // --- 2-1. 계좌번호 --- + + @Test + @DisplayName("2-1-1. 계좌번호 — 명세 예시 형식 (XX-XX-XXXXXX)") + void testAccountNumber_specExample() { + String input = "99-94-123451"; + String result = MaskingUtils.maskAccountNumber(input); + assertEquals("99-9*-*****1", result); + assertEquals(input.length(), result.length()); + } + + @Test + @DisplayName("2-1-2. 계좌번호 — 일반 형식 (XX-XXXXXX-X): 하이픈 원위치 유지") + void testAccountNumber_commonFormat() { + String input = "99-912345-1"; + String result = MaskingUtils.maskAccountNumber(input); + assertEquals("99-9*****-1", result); + assertEquals(input.length(), result.length()); + } + + @Test + @DisplayName("2-1-3. 계좌번호 — 하이픈 없는 형식") + void testAccountNumber_noHyphen() { + String input = "123456789"; + String result = MaskingUtils.maskAccountNumber(input); + assertEquals("123*****9", result); + assertEquals(input.length(), result.length()); + } + + @Test + @DisplayName("2-1-4. 계좌번호 — 4자리 이하는 마스킹 안 함") + void testAccountNumber_tooShort() { + assertEquals("1234", MaskingUtils.maskAccountNumber("1234")); + } + + @Test + @DisplayName("2-1-5. 계좌번호 — null/빈 문자열 방어") + void testAccountNumber_nullAndEmpty() { + assertNull(MaskingUtils.maskAccountNumber(null)); + assertEquals("", MaskingUtils.maskAccountNumber("")); + } + + // --- 2-2. 카드번호 --- + + @Test + @DisplayName("2-2-1. 카드번호 — PCI-DSS 7~12번째 자리 마스킹") + void testCardNumber_normal() { + String input = "4518-4212-3456-1234"; + String result = MaskingUtils.maskCardNumber(input); + assertEquals("4518-42**-****-1234", result); + assertEquals(input.length(), result.length()); + } + + @Test + @DisplayName("2-2-2. 카드번호 — 하이픈 없는 16자리 형식") + void testCardNumber_noHyphen() { + String input = "4518421234561234"; + String result = MaskingUtils.maskCardNumber(input); + assertEquals("451842******1234", result); + assertEquals(input.length(), result.length()); + } + + @Test + @DisplayName("2-2-3. 카드번호 — null/빈 문자열 방어") + void testCardNumber_nullAndEmpty() { + assertNull(MaskingUtils.maskCardNumber(null)); + assertEquals("", MaskingUtils.maskCardNumber("")); + } + + // --- 2-3. 카드 유효기간 --- + + @Test + @DisplayName("2-3-1. 카드 유효기간 — 월/년 전체 마스킹") + void testCardExpiry_normal() { + String input = "12/25"; + String result = MaskingUtils.maskCardExpiry(input); + assertEquals("**/**", result); + assertEquals(input.length(), result.length()); + } + + @Test + @DisplayName("2-3-2. 카드 유효기간 — 구분자 없는 형식") + void testCardExpiry_noDelimiter() { + String input = "1225"; + String result = MaskingUtils.maskCardExpiry(input); + assertEquals("****", result); + assertEquals(input.length(), result.length()); + } + + // --- 2-4. CVV --- + + @Test + @DisplayName("2-4-1. CVV — 전체 마스킹") + void testCvv_normal() { + String input = "123"; + String result = MaskingUtils.maskCvv(input); + assertEquals("***", result); + assertEquals(input.length(), result.length()); + } + + @Test + @DisplayName("2-4-2. CVV — null/빈 문자열 방어") + void testCvv_nullAndEmpty() { + assertNull(MaskingUtils.maskCvv(null)); + assertEquals("", MaskingUtils.maskCvv("")); + } + + // ========================================================================= + // 3. 인증정보 + // ========================================================================= + + // --- 3-1. 비밀번호 --- + + @Test + @DisplayName("3-1-1. 비밀번호 — 전체 자리 마스킹") + void testPassword_normal() { + String input = "password"; + String result = MaskingUtils.maskPassword(input); + assertEquals("********", result); + assertEquals(input.length(), result.length()); + } + + @Test + @DisplayName("3-1-2. 비밀번호 — 숫자/특수문자 혼합") + void testPassword_mixed() { + String input = "Pass1234!@"; + String result = MaskingUtils.maskPassword(input); + assertEquals("**********", result); + assertEquals(input.length(), result.length()); + } + + @Test + @DisplayName("3-1-3. 비밀번호 — null/빈 문자열 방어") + void testPassword_nullAndEmpty() { + assertNull(MaskingUtils.maskPassword(null)); + assertEquals("", MaskingUtils.maskPassword("")); + } + + // ========================================================================= + // 4. 신상정보 + // ========================================================================= + + // --- 4-1. 전화번호/휴대폰 --- + + @Test + @DisplayName("4-1-1. 전화번호 — 가운데·뒷자리 각 2자리 마스킹 (지역)") + void testPhoneNumber_local() { + String input = "064-1234-5678"; + String result = MaskingUtils.maskPhoneNumber(input); + assertEquals("064-12**-56**", result); + assertEquals(input.length(), result.length()); + } + + @Test + @DisplayName("4-1-2. 전화번호 — 서울 02 형식") + void testPhoneNumber_seoul() { + String input = "02-1234-5678"; + String result = MaskingUtils.maskPhoneNumber(input); + assertEquals("02-12**-56**", result); + assertEquals(input.length(), result.length()); + } + + @Test + @DisplayName("4-1-3. 전화번호 — 010 형식 (하이픈 있음)") + void testMobileNumber_normal() { + String input = "010-1234-5678"; + String result = MaskingUtils.maskMobileNumber(input); + assertEquals("010-12**-56**", result); + assertEquals(input.length(), result.length()); + } + + @Test + @DisplayName("4-1-4. 전화번호 — 하이픈 없는 11자리 휴대폰") + void testPhoneNumber_noHyphen_mobile() { + String input = "01012345678"; + String result = MaskingUtils.maskPhoneNumber(input); + assertEquals("01012**56**", result); + assertEquals(input.length(), result.length()); + } + + @Test + @DisplayName("4-1-5. 전화번호 — 하이픈 없는 10자리 서울(02)") + void testPhoneNumber_noHyphen_seoul10() { + String input = "0212345678"; + String result = MaskingUtils.maskPhoneNumber(input); + assertEquals("0212**56**", result); + assertEquals(input.length(), result.length()); + } + + @Test + @DisplayName("4-1-6. 전화번호 — 하이픈 없는 9자리 서울(02)") + void testPhoneNumber_noHyphen_seoul9() { + String input = "021234567"; + String result = MaskingUtils.maskPhoneNumber(input); + assertEquals("021**45**", result); + assertEquals(input.length(), result.length()); + } + + @Test + @DisplayName("4-1-7. 전화번호 — 하이픈 없는 10자리 지역(0XX-XXX-XXXX)") + void testPhoneNumber_noHyphen_local10() { + String input = "0641234567"; + String result = MaskingUtils.maskPhoneNumber(input); + assertEquals("0641**45**", result); + assertEquals(input.length(), result.length()); + } + + @Test + @DisplayName("4-1-8. 전화번호 — 하이픈 없는 11자리 지역(0XX-XXXX-XXXX)") + void testPhoneNumber_noHyphen_local11() { + String input = "06412345678"; + String result = MaskingUtils.maskPhoneNumber(input); + assertEquals("06412**56**", result); + assertEquals(input.length(), result.length()); + } + + @Test + @DisplayName("4-1-9. 전화번호 — 인식 불가 형식은 원본 반환") + void testPhoneNumber_invalidFormat() { + assertEquals("0641234", MaskingUtils.maskPhoneNumber("0641234")); + } + + @Test + @DisplayName("4-1-10. 전화번호 — null/빈 문자열 방어") + void testPhoneNumber_nullAndEmpty() { + assertNull(MaskingUtils.maskPhoneNumber(null)); + assertEquals("", MaskingUtils.maskPhoneNumber("")); + } + + // --- 4-2. 이메일 --- + + @Test + @DisplayName("4-2-1. 이메일 — 앞 2자리·@ 앞 2자리 마스킹") + void testEmail_normal() { + String input = "aajjbacc@shinhan.com"; + String result = MaskingUtils.maskEmail(input); + assertEquals("**jjba**@shinhan.com", result); + assertEquals(input.length(), result.length()); + } + + @Test + @DisplayName("4-2-2. 이메일 — 로컬 파트 4자 이하는 전체 마스킹") + void testEmail_shortLocal() { + String input = "abc@test.com"; + String result = MaskingUtils.maskEmail(input); + assertEquals("***@test.com", result); + assertEquals(input.length(), result.length()); + } + + @Test + @DisplayName("4-2-3. 이메일 — @ 없는 경우 원본 반환") + void testEmail_noAtSign() { + assertEquals("notanemail", MaskingUtils.maskEmail("notanemail")); + } + + @Test + @DisplayName("4-2-4. 이메일 — null/빈 문자열 방어") + void testEmail_nullAndEmpty() { + assertNull(MaskingUtils.maskEmail(null)); + assertEquals("", MaskingUtils.maskEmail("")); + } + + // --- 4-3. 한글 성명 --- + + @Test + @DisplayName("4-3-1. 한글 성명 — 2자: 뒤 1자리 마스킹") + void testKoreanName_2chars() { + String input = "홍동"; + String result = MaskingUtils.maskKoreanName(input); + assertEquals("홍*", result); + assertEquals(input.length(), result.length()); + } + + @Test + @DisplayName("4-3-2. 한글 성명 — 3자: 가운데 1자리 마스킹") + void testKoreanName_3chars() { + String input = "홍길동"; + String result = MaskingUtils.maskKoreanName(input); + assertEquals("홍*동", result); + assertEquals(input.length(), result.length()); + } + + @Test + @DisplayName("4-3-3. 한글 성명 — 4자: 앞뒤 1자 제외 가운데 전체 마스킹") + void testKoreanName_4chars() { + String input = "홍길순동"; + String result = MaskingUtils.maskKoreanName(input); + assertEquals("홍**동", result); + assertEquals(input.length(), result.length()); + } + + @Test + @DisplayName("4-3-4. 한글 성명 — 한자 2자 성명") + void testKoreanName_hanja() { + String input = "田中"; + String result = MaskingUtils.maskKoreanName(input); + assertEquals("田*", result); + assertEquals(input.length(), result.length()); + } + + @Test + @DisplayName("4-3-5. 한글 성명 — 1자는 마스킹 없이 반환") + void testKoreanName_1char() { + String input = "홍"; + String result = MaskingUtils.maskKoreanName(input); + assertEquals("홍", result); + assertEquals(input.length(), result.length()); + } + + @Test + @DisplayName("4-3-6. 한글 성명 — null/빈 문자열 방어") + void testKoreanName_nullAndEmpty() { + assertNull(MaskingUtils.maskKoreanName(null)); + assertEquals("", MaskingUtils.maskKoreanName("")); + } + + // --- 4-4. 영문 성명 --- + + @Test + @DisplayName("4-4-1. 영문 성명 — 이름(first name) 마스킹·성(last name) 노출") + void testEnglishName_normal() { + String input = "GILDONG HONG"; + String result = MaskingUtils.maskEnglishName(input); + assertEquals("******* HONG", result); + assertEquals(input.length(), result.length()); + } + + @Test + @DisplayName("4-4-2. 영문 성명 — 이름이 2개인 경우") + void testEnglishName_multipleFirstNames() { + String input = "MARY GRACE HONG"; + String result = MaskingUtils.maskEnglishName(input); + assertEquals("**** ***** HONG", result); + assertEquals(input.length(), result.length()); + } + + @Test + @DisplayName("4-4-3. 영문 성명 — 단일 토큰은 마스킹 없이 반환") + void testEnglishName_singleToken() { + assertEquals("HONG", MaskingUtils.maskEnglishName("HONG")); + } + + @Test + @DisplayName("4-4-4. 영문 성명 — null/빈 문자열 방어") + void testEnglishName_nullAndEmpty() { + assertNull(MaskingUtils.maskEnglishName(null)); + assertEquals("", MaskingUtils.maskEnglishName("")); + } + + // --- 4-5. 지번주소 --- + + @Test + @DisplayName("4-5-1. 지번주소 — 번지 마스킹 및 상세주소 전체 마스킹") + void testJibunAddress_normal() { + String input = "제주특별자치도 제주시 연동 123번지, 101동 202호"; + String result = MaskingUtils.maskJibunAddress(input); + assertEquals("제주특별자치도 제주시 연동 ***번지, **** ****", result); + assertEquals(input.length(), result.length()); + } + + @Test + @DisplayName("4-5-2. 지번주소 — 상세주소(쉼표 이후) 없는 경우") + void testJibunAddress_noDetail() { + String input = "제주특별자치도 제주시 연동 123번지"; + String result = MaskingUtils.maskJibunAddress(input); + assertEquals("제주특별자치도 제주시 연동 ***번지", result); + assertEquals(input.length(), result.length()); + } + + // --- 4-6. 도로명주소 --- + + @Test + @DisplayName("4-6-1. 도로명주소 — 도로명 번호 및 번지·상세주소 마스킹 (자릿수 보존)") + void testRoadAddress_normal() { + String input = "제주특별자치도 제주시 은남2길 5, 101동 202호"; + String result = MaskingUtils.maskRoadAddress(input); + assertEquals("제주특별자치도 제주시 은남*길 *, **** ****", result); + assertEquals(input.length(), result.length()); + } + + @Test + @DisplayName("4-6-2. 도로명주소 — 상세주소(쉼표 이후) 없는 경우") + void testRoadAddress_noDetail() { + String input = "제주특별자치도 제주시 은남2길 5"; + String result = MaskingUtils.maskRoadAddress(input); + assertEquals("제주특별자치도 제주시 은남*길 *", result); + assertEquals(input.length(), result.length()); + } + + // ========================================================================= + // 5. 온라인 정보 (IP는 고정 길이 마스킹 — String.length() 보존 단언 없음) + // ========================================================================= + + // --- 5-1. IPv4 --- + + @Test + @DisplayName("5-1-1. IPv4 — 3번째 옥텟 고정 마스킹 (***)") + void testIpv4_normal() { + assertEquals("100.200.***.45", MaskingUtils.maskIpv4("100.200.123.45")); + } + + @Test + @DisplayName("5-1-2. IPv4 — 3rd octet 2자리: *** 고정으로 길이 증가 (의도된 동작)") + void testIpv4_twoDigitOctet() { + String input = "10.0.10.5"; + String result = MaskingUtils.maskIpv4(input); + assertEquals("10.0.***.5", result); + // 2자리(10) → 3자리(***): length 9 → 10 + assertEquals(input.length() + 1, result.length()); + } + + @Test + @DisplayName("5-1-3. IPv4 — 3rd octet 1자리: *** 고정으로 길이 증가 (의도된 동작)") + void testIpv4_oneDigitOctet() { + String input = "10.0.1.5"; + String result = MaskingUtils.maskIpv4(input); + assertEquals("10.0.***.5", result); + // 1자리(1) → 3자리(***): length 8 → 10 + assertEquals(input.length() + 2, result.length()); + } + + @Test + @DisplayName("5-1-4. IPv4 — 형식이 아닌 경우 원본 반환") + void testIpv4_invalidFormat() { + assertEquals("100.200.123", MaskingUtils.maskIpv4("100.200.123")); + } + + @Test + @DisplayName("5-1-5. IPv4 — null/빈 문자열 방어") + void testIpv4_nullAndEmpty() { + assertNull(MaskingUtils.maskIpv4(null)); + assertEquals("", MaskingUtils.maskIpv4("")); + } + + // --- 5-2. IPv6 --- + + @Test + @DisplayName("5-2-1. IPv6 — 마지막 그룹(113~128 비트) 고정 마스킹 (****)") + void testIpv6_normal() { + assertEquals( + "21DA:00D3:0000:2F3B:02AA:00FF:FE28:****", + MaskingUtils.maskIpv6("21DA:00D3:0000:2F3B:02AA:00FF:FE28:9C5A") + ); + } + + @Test + @DisplayName("5-2-2. IPv6 — null/빈 문자열 방어") + void testIpv6_nullAndEmpty() { + assertNull(MaskingUtils.maskIpv6(null)); + assertEquals("", MaskingUtils.maskIpv6("")); + } + + // --- 5-3. IP 자동 판별 --- + + @Test + @DisplayName("5-3-1. IP 자동 판별 — IPv4") + void testIpAddress_detectIpv4() { + assertEquals("192.168.***.1", MaskingUtils.maskIpAddress("192.168.100.1")); + } + + @Test + @DisplayName("5-3-2. IP 자동 판별 — IPv6") + void testIpAddress_detectIpv6() { + assertEquals( + "21DA:00D3:0000:2F3B:02AA:00FF:FE28:****", + MaskingUtils.maskIpAddress("21DA:00D3:0000:2F3B:02AA:00FF:FE28:9C5A") + ); + } + + @Test + @DisplayName("5-3-3. IP 자동 판별 — 쉼표 구분 다중 IPv4 (3자리/1자리 혼합)") + void testIpAddress_multipleIpv4() { + // 192.168.100.1: 3rd octet 3자리 → 길이 유지 + // 10.0.0.2: 3rd octet 1자리(0) → *** 고정으로 길이 증가 + assertEquals("192.168.***.1,10.0.***.2", MaskingUtils.maskIpAddress("192.168.100.1,10.0.0.2")); + } +}