From 1c5b03fb9819e32c759d32452fff350b10f7b249 Mon Sep 17 00:00:00 2001 From: kimsubsub Date: Thu, 14 May 2026 19:08:57 +0900 Subject: [PATCH 01/27] =?UTF-8?q?feat:=20=EC=9C=84=ED=98=91=20=EC=9C=A0?= =?UTF-8?q?=ED=98=95=EB=B3=84=20=EC=83=81=EC=84=B8=20=EC=84=A4=EB=AA=85=20?= =?UTF-8?q?=EB=A7=A4=ED=95=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../constants/riskDetectionCatalog.test.ts | 54 +++++ .../Report/constants/riskDetectionCatalog.ts | 2 +- src/pages/Report/constants/threatText.ts | 185 ++++++++++++++++++ 3 files changed, 240 insertions(+), 1 deletion(-) create mode 100644 src/pages/Report/constants/riskDetectionCatalog.test.ts diff --git a/src/pages/Report/constants/riskDetectionCatalog.test.ts b/src/pages/Report/constants/riskDetectionCatalog.test.ts new file mode 100644 index 0000000..8e8504f --- /dev/null +++ b/src/pages/Report/constants/riskDetectionCatalog.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest'; + +import { resolveRiskDetectionContent } from './riskDetectionCatalog'; + +const backendThreatCodes = [ + 'SHORTENED_URL', + 'percent_encoding_detected', + 'double_encoding_suspected', + 'suspicious_query_param_detected', + 'embedded_url', + 'suspicious_query_keyword_detected', + 'suspicious_path_keyword_detected', + 'suspicious_fragment_keyword_detected', + 'MALWARE', + 'SOCIAL_ENGINEERING', + 'UNWANTED_SOFTWARE', + 'POTENTIALLY_HARMFUL_APPLICATION', + 'PHISHING', + 'RANSOMWARE', + 'BOTNET', + 'SPAM', + 'C2', + 'SUSPICIOUS', + 'hostname_missing', + 'certificate_request_timeout', + 'invalid certificate response', + 'peer certificate not available', +] as const; + +describe('resolveRiskDetectionContent', () => { + it('maps every backend threat code to a dedicated catalog entry', () => { + backendThreatCodes.forEach((threatCode) => { + const content = resolveRiskDetectionContent(threatCode, 'safe'); + + expect(content.englishLabel).not.toBe('UNMAPPED LOW RISK SIGNAL'); + expect(content.description).not.toContain('정책 테이블에 상세 설명이 등록되지 않았습니다.'); + expect(content.title).not.toBe(`${threatCode} 감지`); + }); + }); + + it('maps DNS hostname errors to the hostname failure description', () => { + const content = resolveRiskDetectionContent('[Errno -2] Name or service not known', 'safe'); + + expect(content.englishLabel).toBe('HOSTNAME MISSING'); + expect(content.title).toBe('호스트명 확인 실패 감지'); + }); + + it('ignores array-like punctuation around backend threat codes', () => { + const content = resolveRiskDetectionContent("'PHISHING']", 'safe'); + + expect(content.englishLabel).toBe('PHISHING'); + expect(content.title).toBe('피싱 위협 감지'); + }); +}); diff --git a/src/pages/Report/constants/riskDetectionCatalog.ts b/src/pages/Report/constants/riskDetectionCatalog.ts index d0c1e37..f39c510 100644 --- a/src/pages/Report/constants/riskDetectionCatalog.ts +++ b/src/pages/Report/constants/riskDetectionCatalog.ts @@ -10,7 +10,7 @@ function normalizeRiskType(value: string): string { return value .trim() .toLowerCase() - .replace(/[\s\-_/.,:%@()]/g, ''); + .replace(/[\s\-_/.,:%@()\[\]'"]/g, ''); } const riskDetectionLookup = threatTextCatalog.reduce>( diff --git a/src/pages/Report/constants/threatText.ts b/src/pages/Report/constants/threatText.ts index 6959aa3..2101c44 100644 --- a/src/pages/Report/constants/threatText.ts +++ b/src/pages/Report/constants/threatText.ts @@ -178,6 +178,191 @@ export const threatTextCatalog: RiskDetectionCatalogItem[] = [ risk: '단일 신호만으로 단정 짓기는 어렵지만, 다른 위험 항목과 함께 발견되면 종합적인 위험도가 크게 상승합니다.', title: 'AI 모델 의심 신호 감지', }, + { + description: + 'bit.ly, tinyurl처럼 최종 목적지를 바로 확인하기 어려운 단축 URL 서비스가 사용되었습니다.', + englishLabel: 'SHORTENED URL', + names: ['SHORTENED_URL', 'shortened_url', 'short_url', 'url_shortener'], + risk: '실제 목적지를 숨긴 뒤 피싱 사이트나 악성 파일 배포지로 이동시키는 데 자주 사용됩니다.', + title: '단축 URL 감지', + }, + { + description: 'URL 안에 %2F, %3A처럼 문자를 퍼센트 인코딩으로 감춘 흔적이 확인되었습니다.', + englishLabel: 'PERCENT ENCODING DETECTED', + names: ['percent_encoding_detected', 'percent_encoding', 'url_percent_encoding'], + risk: '위험 키워드나 실제 이동 경로를 숨겨 보안 필터와 사용자의 육안 확인을 우회할 수 있습니다.', + title: '퍼센트 인코딩 감지', + }, + { + description: + 'URL 일부가 한 번 더 인코딩된 형태로 보여, 원래 문자열을 바로 확인하기 어렵습니다.', + englishLabel: 'DOUBLE ENCODING SUSPECTED', + names: ['double_encoding_suspected', 'double_encoding', 'nested_encoding'], + risk: '악성 경로나 파라미터를 단계적으로 숨겨 자동 분석과 차단 규칙을 회피하려는 패턴일 수 있습니다.', + title: '이중 인코딩 의심 감지', + }, + { + description: + '쿼리 파라미터에 redirect, callback, next 등 외부 이동이나 민감 동작과 관련된 의심 패턴이 포함되었습니다.', + englishLabel: 'SUSPICIOUS QUERY PARAMETER', + names: [ + 'suspicious_query_param_detected', + 'suspicious_query_param', + 'suspicious_query_parameter', + ], + risk: '정상 사이트처럼 보이는 주소에서 사용자를 다른 목적지로 넘기거나 피싱 절차를 숨길 수 있습니다.', + title: '의심스러운 쿼리 파라미터 감지', + }, + { + description: + 'URL 내부에 또 다른 URL이 포함되어 있어 최종 이동 목적지가 별도로 숨겨져 있을 가능성이 있습니다.', + englishLabel: 'EMBEDDED URL', + names: ['embedded_url', 'nested_url', 'url_in_url'], + risk: '정상 도메인을 경유지처럼 사용해 사용자를 악성 사이트로 리다이렉트하는 공격에 활용될 수 있습니다.', + title: 'URL 내부 삽입 주소 감지', + }, + { + description: + '쿼리 문자열에서 login, verify, password, wallet 같은 민감 행동을 유도하는 키워드가 확인되었습니다.', + englishLabel: 'SUSPICIOUS QUERY KEYWORD', + names: [ + 'suspicious_query_keyword_detected', + 'suspicious_query_keyword', + 'query_keyword_detected', + ], + risk: '계정 인증, 결제, 지갑 연결처럼 사용자의 민감 정보를 입력하게 만드는 피싱 흐름일 수 있습니다.', + title: '쿼리 위험 키워드 감지', + }, + { + description: + 'URL 경로에서 login, update, secure, download 등 공격자가 자주 쓰는 유도성 키워드가 감지되었습니다.', + englishLabel: 'SUSPICIOUS PATH KEYWORD', + names: ['suspicious_path_keyword_detected', 'suspicious_path_keyword', 'path_keyword_detected'], + risk: '공식 로그인, 보안 업데이트, 파일 다운로드처럼 보이게 만들어 클릭과 정보 입력을 유도할 수 있습니다.', + title: '경로 위험 키워드 감지', + }, + { + description: + 'URL fragment 영역(# 뒤 문자열)에 민감 행동을 유도하는 의심 키워드가 포함되었습니다.', + englishLabel: 'SUSPICIOUS FRAGMENT KEYWORD', + names: [ + 'suspicious_fragment_keyword_detected', + 'suspicious_fragment_keyword', + 'fragment_keyword_detected', + ], + risk: '서버 로그에 잘 남지 않는 fragment를 이용해 피싱 화면 상태나 악성 동작 정보를 숨길 수 있습니다.', + title: '프래그먼트 위험 키워드 감지', + }, + { + description: '외부 위협 정보에서 악성코드 배포 또는 악성 행위와 관련된 URL로 분류되었습니다.', + englishLabel: 'MALWARE', + names: ['MALWARE', 'malware'], + risk: '접속만으로 악성 파일 다운로드, 브라우저 악용, 계정 정보 탈취 같은 피해로 이어질 수 있습니다.', + title: '악성코드 위협 감지', + }, + { + description: + '사용자를 속여 로그인, 결제, 설치, 권한 허용 같은 행동을 하게 만드는 사회공학 공격 신호가 확인되었습니다.', + englishLabel: 'SOCIAL ENGINEERING', + names: ['SOCIAL_ENGINEERING', 'social_engineering'], + risk: '정상 안내처럼 보이지만 민감 정보 입력이나 악성 앱 설치를 유도해 직접적인 피해를 만들 수 있습니다.', + title: '사회공학 위협 감지', + }, + { + description: + '원치 않는 광고, 브라우저 설정 변경, 번들 프로그램 설치 등 사용자에게 불필요하거나 해로운 소프트웨어와 관련된 신호입니다.', + englishLabel: 'UNWANTED SOFTWARE', + names: ['UNWANTED_SOFTWARE', 'unwanted_software'], + risk: '명확한 동의 없이 프로그램이 설치되거나 환경 설정이 바뀌어 개인정보 노출과 추가 악성 행위로 이어질 수 있습니다.', + title: '원치 않는 소프트웨어 감지', + }, + { + description: '잠재적으로 유해한 앱 설치 또는 실행을 유도하는 URL로 분류되었습니다.', + englishLabel: 'POTENTIALLY HARMFUL APPLICATION', + names: ['POTENTIALLY_HARMFUL_APPLICATION', 'potentially_harmful_application', 'pha'], + risk: '문자, 연락처, 인증 정보 같은 권한을 악용하거나 원격 제어 기능으로 금융 피해를 유발할 수 있습니다.', + title: '잠재적 유해 앱 감지', + }, + { + description: '로그인 정보, 결제 정보, 인증 코드 등을 탈취하려는 피싱 URL로 분류되었습니다.', + englishLabel: 'PHISHING', + names: ['PHISHING', 'phishing'], + risk: '공식 사이트와 유사한 화면으로 사용자를 속여 계정 탈취, 결제 피해, 추가 사기 피해로 이어질 수 있습니다.', + title: '피싱 위협 감지', + }, + { + description: '랜섬웨어 유포 또는 랜섬웨어 공격 인프라와 관련된 신호가 확인되었습니다.', + englishLabel: 'RANSOMWARE', + names: ['RANSOMWARE', 'ransomware'], + risk: '악성 파일 실행 시 기기나 파일이 암호화되고 금전 요구, 업무 중단, 데이터 손실로 이어질 수 있습니다.', + title: '랜섬웨어 위협 감지', + }, + { + description: '감염된 기기 네트워크나 자동화된 악성 트래픽과 관련된 봇넷 신호가 확인되었습니다.', + englishLabel: 'BOTNET', + names: ['BOTNET', 'botnet'], + risk: '접속한 기기가 악성 네트워크에 연결되거나 스팸 발송, 디도스, 추가 감염에 악용될 수 있습니다.', + title: '봇넷 위협 감지', + }, + { + description: '대량 발송 스팸, 사기성 광고, 악성 링크 배포와 관련된 URL로 분류되었습니다.', + englishLabel: 'SPAM', + names: ['SPAM', 'spam'], + risk: '반복적인 사기 메시지나 악성 링크 유입 경로로 사용되어 피싱, 결제 사기, 악성 앱 설치로 이어질 수 있습니다.', + title: '스팸 위협 감지', + }, + { + description: + '악성코드가 명령을 받거나 탈취 정보를 전송하는 C2(Command and Control) 인프라 신호가 감지되었습니다.', + englishLabel: 'COMMAND AND CONTROL', + names: ['C2', 'c2', 'command_and_control', 'command_control'], + risk: '이미 감염된 기기를 제어하거나 추가 명령을 내려 정보 탈취와 내부 확산을 수행할 수 있습니다.', + title: 'C2 통신 위협 감지', + }, + { + description: + '외부 평판 또는 분석 결과에서 명확한 세부 분류는 없지만 의심 URL로 표시되었습니다.', + englishLabel: 'SUSPICIOUS', + names: ['SUSPICIOUS', 'suspicious'], + risk: '단일 신호로 단정할 수는 없지만 다른 탐지 항목과 함께 나타나면 접속 위험도가 크게 올라갑니다.', + title: '의심 URL 감지', + }, + { + description: 'URL에서 접속 대상 호스트명을 확인하지 못했거나 DNS 해석에 실패한 신호입니다.', + englishLabel: 'HOSTNAME MISSING', + names: [ + 'hostname_missing', + 'missing_hostname', + 'host_missing', + 'dns_resolution_failed', + '[Errno -2] Name or service not known', + 'name_or_service_not_known', + ], + risk: '정상적인 접속 대상이 불명확해 분석이 제한되며, 잘못 구성된 URL 또는 일시적인 악성 인프라일 수 있습니다.', + title: '호스트명 확인 실패 감지', + }, + { + description: '인증서 정보를 확인하는 요청이 제한 시간 안에 완료되지 않았습니다.', + englishLabel: 'CERTIFICATE REQUEST TIMEOUT', + names: ['certificate_request_timeout', 'certificate_timeout', 'tls_certificate_timeout'], + risk: 'HTTPS 인증 상태를 충분히 검증하지 못해 서버 신뢰성을 판단하기 어렵고, 불안정하거나 은폐된 서버일 수 있습니다.', + title: '인증서 요청 시간 초과 감지', + }, + { + description: + '인증서 검증 과정에서 유효하지 않은 응답이 반환되어 HTTPS 신뢰 정보를 정상적으로 해석하지 못했습니다.', + englishLabel: 'INVALID CERTIFICATE RESPONSE', + names: ['invalid certificate response', 'invalid_certificate_response'], + risk: '인증서 체인이나 검증 응답이 비정상적이면 중간자 공격, 잘못된 서버 설정, 위장 서버 가능성을 확인해야 합니다.', + title: '유효하지 않은 인증서 응답 감지', + }, + { + description: 'HTTPS 연결 대상 서버에서 확인 가능한 피어 인증서를 제공하지 않았습니다.', + englishLabel: 'PEER CERTIFICATE NOT AVAILABLE', + names: ['peer certificate not available', 'peer_certificate_not_available'], + risk: '서버 신원을 확인할 수 없어 민감 정보 입력 시 도청, 위장 서버, 피싱 위험을 배제하기 어렵습니다.', + title: '피어 인증서 미제공 감지', + }, ]; export const unknownThreatTextByTone: Record = { From b8dd17758b88927a801a38c271f634f51087b3be Mon Sep 17 00:00:00 2001 From: kimsubsub Date: Thu, 14 May 2026 19:09:19 +0900 Subject: [PATCH 02/27] =?UTF-8?q?feat:=20=ED=8F=89=ED=8C=90=20=EC=84=B9?= =?UTF-8?q?=EC=85=98=20=EC=A7=80=ED=91=9C=20=EA=B0=9C=ED=8E=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/Report/ReportPage.tsx | 28 +++----- src/pages/Report/lib/toReportPageData.test.ts | 17 +++-- src/pages/Report/lib/toReportPageData.ts | 65 ++++++++++++------- src/pages/Report/styles/reportPage.css.ts | 2 +- src/pages/Report/types/reportPage.types.ts | 5 +- 5 files changed, 64 insertions(+), 53 deletions(-) diff --git a/src/pages/Report/ReportPage.tsx b/src/pages/Report/ReportPage.tsx index eb059bf..7948943 100644 --- a/src/pages/Report/ReportPage.tsx +++ b/src/pages/Report/ReportPage.tsx @@ -62,10 +62,7 @@ export default function ReportPage() { window.print(); }; - const totalReputationCount = - reportPageData.reputation.summary.phishingCount + - reportPageData.reputation.summary.malwareCount + - reportPageData.reputation.summary.spamCount; + const reputationReportCount = reportPageData.reputation.summary.reportCount; const originalProtocolTone = resolveProtocolTone(reportPageData.urlAnalysis.originalUrl); const destinationProtocolTone = resolveProtocolTone(reportPageData.urlAnalysis.destinationUrl); @@ -73,8 +70,8 @@ export default function ReportPage() { const hasInsecureProtocol = originalProtocolTone !== 'secure' || destinationProtocolTone !== 'secure'; - const reputationBadgeTone = totalReputationCount === 0 ? 'clean' : 'warning'; - const providerStatusTone = totalReputationCount === 0 ? 'safe' : reportPageData.riskLevel; + const reputationBadgeTone = reputationReportCount === 0 ? 'clean' : 'warning'; + const providerStatusTone = reputationReportCount === 0 ? 'safe' : reportPageData.riskLevel; const detectedRiskCards = reportPageData.detectedRiskTypes.map((riskType, index) => ({ ...resolveRiskDetectionContent(riskType, reportPageData.riskLevel), @@ -197,9 +194,9 @@ export default function ReportPage() { {sectionNumber.reputation}
-

평판 및 차단 기록 조회

+

평판 및 도메인 정보 조회

- 글로벌 블랙리스트 및 위협 정보 기준 조회 결과입니다. + 신고 이력과 도메인 생성 정보를 기준으로 조회한 결과입니다.

@@ -213,23 +210,16 @@ export default function ReportPage() {
-

피싱 사이트

+

신고 건수

- {reportPageData.reputation.summary.phishingCount}건 + {reportPageData.reputation.summary.reportCount}건

-

악성코드 유포

+

도메인 생성일

- {reportPageData.reputation.summary.malwareCount}건 -

-
- -
-

스팸 기록

-

- {reportPageData.reputation.summary.spamCount}건 + {reportPageData.reputation.summary.domainAgeText}

diff --git a/src/pages/Report/lib/toReportPageData.test.ts b/src/pages/Report/lib/toReportPageData.test.ts index 994a978..e2c9016 100644 --- a/src/pages/Report/lib/toReportPageData.test.ts +++ b/src/pages/Report/lib/toReportPageData.test.ts @@ -22,9 +22,8 @@ describe('toReportPageData', () => { providerName: 'Google Safe Browsing', providerStatusText: 'checked', summary: { - malwareCount: 1, - phishingCount: 2, - spamCount: 0, + domainAge: '120', + reportCount: 2, }, }, riskLevel: 'HIGH', @@ -52,7 +51,8 @@ describe('toReportPageData', () => { expect(reportPageData.riskLevel).toBe('critical'); expect(reportPageData.trustScore).toBe(83); expect(reportPageData.urlAnalysis.destinationUrl).toBe('https://danger.example'); - expect(reportPageData.reputation.summary.phishingCount).toBe(2); + expect(reportPageData.reputation.summary.reportCount).toBe(2); + expect(reportPageData.reputation.summary.domainAgeText).toBe('120일'); expect(reportPageData.serverInfo.certificateStatusTone).toBe('error'); }); @@ -65,8 +65,10 @@ describe('toReportPageData', () => { provider: 'Google Safe Browsing', result: 'False', }, + blockCount: null, + domainAge: '4055', internalDb: { - blockCount: 0, + blockCount: null, exists: false, reportCount: 0, }, @@ -75,6 +77,7 @@ describe('toReportPageData', () => { threats: ['dummy_ml_threat'], }, originalUrl: 'https://malware.testing.google/', + reportCount: 0, redirect: { finalUrl: 'https://malware.testing.google/', redirectCount: 0, @@ -110,6 +113,10 @@ describe('toReportPageData', () => { expect(reportPageData.urlAnalysis.destinationUrl).toBe('https://malware.testing.google/'); expect(reportPageData.detectedRiskTypes).toContain('dummy_ml_threat'); expect(reportPageData.reputation.providerName).toBe('Google Safe Browsing'); + expect(reportPageData.reputation.summary).toEqual({ + domainAgeText: '4,055일', + reportCount: 0, + }); expect(reportPageData.serverInfo.certificateStatusTone).toBe('error'); }); diff --git a/src/pages/Report/lib/toReportPageData.ts b/src/pages/Report/lib/toReportPageData.ts index 63a8768..2ef7ce9 100644 --- a/src/pages/Report/lib/toReportPageData.ts +++ b/src/pages/Report/lib/toReportPageData.ts @@ -1,7 +1,6 @@ import { asRecord, pickBoolean, - pickNumber, pickRecord, pickSourceNumber, pickSourceRecord, @@ -189,16 +188,36 @@ function resolveExternalApiStatus( return `검사 결과: ${result}`; } -function resolveSummaryCount( - primaryRecord: Record | null, - primaryKeys: string[], - fallbackRecord?: Record | null, - fallbackKeys?: string[], -): number { - return clampCount( - pickNumber(primaryRecord, primaryKeys) ?? - (fallbackRecord && fallbackKeys ? pickNumber(fallbackRecord, fallbackKeys) : null), - ); +function resolveSummaryCount(sources: unknown[], keys: string[]): number { + return clampCount(pickSourceNumber(sources, keys)); +} + +function formatDomainAgeText(rawValue: string | number | null): string { + if (rawValue === null) { + return missingReportInfoLabel; + } + + const valueText = `${rawValue}`.trim(); + + if (!valueText) { + return missingReportInfoLabel; + } + + if (/^\d+(?:\.\d+)?$/u.test(valueText)) { + return `${Math.max(0, Math.round(Number(valueText))).toLocaleString('ko-KR')}일`; + } + + return valueText; +} + +function resolveDomainAgeText(sources: unknown[]): string { + const stringValue = pickSourceString(sources, ['domainAge', 'domain_age']); + + if (stringValue) { + return formatDomainAgeText(stringValue); + } + + return formatDomainAgeText(pickSourceNumber(sources, ['domainAge', 'domain_age'])); } function resolveServerInfoRecord(rawServerInfoRecord: Record | null) { @@ -258,9 +277,11 @@ function buildDomainComparison( function buildReputation( reputationRecord: Record | null, internalDbRecord: Record | null, + sources: unknown[], ): ReportPageData['reputation'] { const reputationSummaryRecord = pickRecord(reputationRecord, ['summary']) ?? asRecord(reputationRecord); + const metricSources = [reputationSummaryRecord, reputationRecord, internalDbRecord, ...sources]; return { detailDescription: @@ -274,19 +295,13 @@ function buildReputation( resolveExternalApiStatus(reputationRecord) ?? '검사 완료', summary: { - malwareCount: resolveSummaryCount( - reputationSummaryRecord, - ['malwareCount', 'malware_count'], - internalDbRecord, - ['blockCount', 'block_count'], - ), - phishingCount: resolveSummaryCount( - reputationSummaryRecord, - ['phishingCount', 'phishing_count'], - internalDbRecord, - ['reportCount', 'report_count'], - ), - spamCount: resolveSummaryCount(reputationSummaryRecord, ['spamCount', 'spam_count']), + domainAgeText: resolveDomainAgeText(metricSources), + reportCount: resolveSummaryCount(metricSources, [ + 'reportCount', + 'report_count', + 'phishingCount', + 'phishing_count', + ]), }, }; } @@ -339,7 +354,7 @@ export function toReportPageData(session: ScanSessionSnapshot): ReportPageData { return { detectedRiskTypes: resolveDetectedRiskTypes(sources, riskLevel), domainComparison: buildDomainComparison(domainComparisonRecord, riskLevel, urls), - reputation: buildReputation(reputationRecord, internalDbRecord), + reputation: buildReputation(reputationRecord, internalDbRecord, sources), reportTitle: '상세 분석 리포트', riskDescription: reportFallbackCopyByTone[riskLevel].riskDescription, riskLevel, diff --git a/src/pages/Report/styles/reportPage.css.ts b/src/pages/Report/styles/reportPage.css.ts index c5b183d..f1ca5a9 100644 --- a/src/pages/Report/styles/reportPage.css.ts +++ b/src/pages/Report/styles/reportPage.css.ts @@ -791,7 +791,7 @@ export const reputationStatGrid = style({ gap: vars.spacing.sm, '@media': { [bpTablet]: { - gridTemplateColumns: 'repeat(3, minmax(0, 1fr))', + gridTemplateColumns: 'repeat(2, minmax(0, 1fr))', }, }, }); diff --git a/src/pages/Report/types/reportPage.types.ts b/src/pages/Report/types/reportPage.types.ts index 19f0d21..6157383 100644 --- a/src/pages/Report/types/reportPage.types.ts +++ b/src/pages/Report/types/reportPage.types.ts @@ -3,9 +3,8 @@ import type { ResultTone } from '@/shared/types/resultTone'; export type ReportStatusTone = 'error' | 'success' | 'warning'; export type ReportReputationSummary = { - malwareCount: number; - phishingCount: number; - spamCount: number; + domainAgeText: string; + reportCount: number; }; export type ReportServerInfo = { From bd1cafa3cd5644fb0d20028f63d039def90922c5 Mon Sep 17 00:00:00 2001 From: kimsubsub Date: Thu, 14 May 2026 19:09:40 +0900 Subject: [PATCH 03/27] =?UTF-8?q?feat:=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20QR?= =?UTF-8?q?=20=EC=BD=94=EB=93=9C=20=EC=83=9D=EC=84=B1=20=EB=AA=85=EB=A0=B9?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + README.md | 21 +++ package.json | 3 + pnpm-lock.yaml | 214 ++++++++++++++++++++++++++++++ src/test-code/generate-qr-png.mjs | 81 +++++++++++ 5 files changed, 320 insertions(+) create mode 100644 src/test-code/generate-qr-png.mjs diff --git a/.gitignore b/.gitignore index 2f8b07a..5b60d74 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,4 @@ out docs/local/ .claude/ .devserver.*.log +src/test-code/generated-qr/ diff --git a/README.md b/README.md index a063d68..f274565 100644 --- a/README.md +++ b/README.md @@ -48,9 +48,30 @@ pnpm lint:fix pnpm format pnpm format:write pnpm test +pnpm test-code pnpm security:check ``` +## Test QR Code + +테스트할 URL을 QR 이미지로 만들 때 사용합니다. + +```bash +pnpm test-code https://naver.com +``` + +`//`가 빠진 값도 자동 보정됩니다. + +```bash +pnpm test-code: https:naver.com +``` + +생성된 PNG는 `src/test-code/generated-qr/`에 저장됩니다. 쿼리스트링에 `&`가 들어간 URL은 PowerShell에서 분리될 수 있으니 따옴표로 감싸서 실행합니다. + +```bash +pnpm test-code "https://example.com/?a=1&b=2" +``` + ## Documents - [협업 가이드](./CONTRIBUTING.md) diff --git a/package.json b/package.json index 239e4ce..ef28690 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,8 @@ "format": "prettier --check .", "format:write": "prettier --write .", "test": "vitest run", + "test-code": "node src/test-code/generate-qr-png.mjs", + "test-code:": "node src/test-code/generate-qr-png.mjs", "security:check": "secretlint \"**/*\" && pnpm audit --audit-level high" }, "lint-staged": { @@ -56,6 +58,7 @@ "lefthook": "^1.13.6", "lint-staged": "^16.2.0", "prettier": "^3.8.1", + "qrcode": "^1.5.4", "secretlint": "^11.3.0", "typescript": "^5.9.3", "vite": "^7.3.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ddbf624..32adb4f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -78,6 +78,9 @@ importers: prettier: specifier: ^3.8.1 version: 3.8.1 + qrcode: + specifier: ^1.5.4 + version: 1.5.4 secretlint: specifier: ^11.3.0 version: 11.3.1 @@ -1985,6 +1988,13 @@ packages: } engines: { node: '>=6' } + camelcase@5.3.1: + resolution: + { + integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==, + } + engines: { node: '>=6' } + caniuse-lite@1.0.30001777: resolution: { @@ -2033,6 +2043,12 @@ packages: } engines: { node: '>=20' } + cliui@6.0.0: + resolution: + { + integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==, + } + clsx@2.1.1: resolution: { @@ -2187,6 +2203,13 @@ packages: supports-color: optional: true + decamelize@1.2.0: + resolution: + { + integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==, + } + engines: { node: '>=0.10.0' } + dedent@1.7.2: resolution: { @@ -2245,6 +2268,12 @@ packages: } engines: { node: '>=0.4.0' } + dijkstrajs@1.0.3: + resolution: + { + integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==, + } + doctrine@2.1.0: resolution: { @@ -2585,6 +2614,13 @@ packages: } engines: { node: '>=8' } + find-up@4.1.0: + resolution: + { + integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==, + } + engines: { node: '>=8' } + find-up@5.0.0: resolution: { @@ -2672,6 +2708,13 @@ packages: } engines: { node: '>=6.9.0' } + get-caller-file@2.0.5: + resolution: + { + integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==, + } + engines: { node: 6.* || 8.* || >= 10.* } + get-east-asian-width@1.5.0: resolution: { @@ -3259,6 +3302,13 @@ packages: } engines: { node: '>=20.0.0' } + locate-path@5.0.0: + resolution: + { + integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==, + } + engines: { node: '>=8' } + locate-path@6.0.0: resolution: { @@ -3490,6 +3540,13 @@ packages: } engines: { node: '>= 0.4' } + p-limit@2.3.0: + resolution: + { + integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==, + } + engines: { node: '>=6' } + p-limit@3.1.0: resolution: { @@ -3497,6 +3554,13 @@ packages: } engines: { node: '>=10' } + p-locate@4.1.0: + resolution: + { + integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==, + } + engines: { node: '>=8' } + p-locate@5.0.0: resolution: { @@ -3511,6 +3575,13 @@ packages: } engines: { node: '>=18' } + p-try@2.2.0: + resolution: + { + integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==, + } + engines: { node: '>=6' } + parent-module@1.0.1: resolution: { @@ -3604,6 +3675,13 @@ packages: } engines: { node: '>=4' } + pngjs@5.0.0: + resolution: + { + integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==, + } + engines: { node: '>=10.13.0' } + possible-typed-array-names@1.1.0: resolution: { @@ -3647,6 +3725,14 @@ packages: } engines: { node: '>=6' } + qrcode@1.5.4: + resolution: + { + integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==, + } + engines: { node: '>=10.13.0' } + hasBin: true + queue-microtask@1.2.3: resolution: { @@ -3708,6 +3794,13 @@ packages: } engines: { node: '>= 0.4' } + require-directory@2.1.1: + resolution: + { + integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==, + } + engines: { node: '>=0.10.0' } + require-from-string@2.0.2: resolution: { @@ -3721,6 +3814,12 @@ packages: integrity: sha512-oyrU88skkMtDdauHDuKVrgR+zuItqr6/c//FXzvmxRGMexSDc6hNvJInGW3LL46n+8b50RykrvwSUIIQH2LQ5A==, } + require-main-filename@2.0.0: + resolution: + { + integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==, + } + resolve-from@4.0.0: resolution: { @@ -3848,6 +3947,12 @@ packages: } engines: { node: '>=10' } + set-blocking@2.0.0: + resolution: + { + integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==, + } + set-function-length@1.2.2: resolution: { @@ -4490,6 +4595,12 @@ packages: } engines: { node: '>= 0.4' } + which-module@2.0.1: + resolution: + { + integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==, + } + which-typed-array@1.1.20: resolution: { @@ -4520,6 +4631,13 @@ packages: } engines: { node: '>=0.10.0' } + wrap-ansi@6.2.0: + resolution: + { + integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==, + } + engines: { node: '>=8' } + wrap-ansi@9.0.2: resolution: { @@ -4527,6 +4645,12 @@ packages: } engines: { node: '>=18' } + y18n@4.0.3: + resolution: + { + integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==, + } + yallist@3.1.1: resolution: { @@ -4541,6 +4665,20 @@ packages: engines: { node: '>= 14.6' } hasBin: true + yargs-parser@18.1.3: + resolution: + { + integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==, + } + engines: { node: '>=6' } + + yargs@15.4.1: + resolution: + { + integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==, + } + engines: { node: '>=8' } + yocto-queue@0.1.0: resolution: { @@ -5929,6 +6067,8 @@ snapshots: callsites@3.1.0: {} + camelcase@5.3.1: {} + caniuse-lite@1.0.30001777: {} chai@5.3.3: @@ -5957,6 +6097,12 @@ snapshots: slice-ansi: 8.0.0 string-width: 8.2.0 + cliui@6.0.0: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 6.2.0 + clsx@2.1.1: {} color-convert@2.0.1: @@ -6026,6 +6172,8 @@ snapshots: dependencies: ms: 2.1.3 + decamelize@1.2.0: {} + dedent@1.7.2: {} deep-eql@5.0.2: {} @@ -6050,6 +6198,8 @@ snapshots: delayed-stream@1.0.0: {} + dijkstrajs@1.0.3: {} + doctrine@2.1.0: dependencies: esutils: 2.0.3 @@ -6353,6 +6503,11 @@ snapshots: dependencies: to-regex-range: 5.0.1 + find-up@4.1.0: + dependencies: + locate-path: 5.0.0 + path-exists: 4.0.0 + find-up@5.0.0: dependencies: locate-path: 6.0.0 @@ -6399,6 +6554,8 @@ snapshots: gensync@1.0.0-beta.2: {} + get-caller-file@2.0.5: {} + get-east-asian-width@1.5.0: {} get-intrinsic@1.3.0: @@ -6729,6 +6886,10 @@ snapshots: rfdc: 1.4.1 wrap-ansi: 9.0.2 + locate-path@5.0.0: + dependencies: + p-locate: 4.1.0 + locate-path@6.0.0: dependencies: p-locate: 5.0.0 @@ -6865,16 +7026,26 @@ snapshots: object-keys: 1.1.1 safe-push-apply: 1.0.0 + p-limit@2.3.0: + dependencies: + p-try: 2.2.0 + p-limit@3.1.0: dependencies: yocto-queue: 0.1.0 + p-locate@4.1.0: + dependencies: + p-limit: 2.3.0 + p-locate@5.0.0: dependencies: p-limit: 3.1.0 p-map@7.0.4: {} + p-try@2.2.0: {} + parent-module@1.0.1: dependencies: callsites: 3.1.0 @@ -6913,6 +7084,8 @@ snapshots: pluralize@8.0.0: {} + pngjs@5.0.0: {} + possible-typed-array-names@1.1.0: {} postcss@8.5.14: @@ -6929,6 +7102,12 @@ snapshots: punycode@2.3.1: {} + qrcode@1.5.4: + dependencies: + dijkstrajs: 1.0.3 + pngjs: 5.0.0 + yargs: 15.4.1 + queue-microtask@1.2.3: {} rc-config-loader@4.1.4: @@ -6979,10 +7158,14 @@ snapshots: gopd: 1.2.0 set-function-name: 2.0.2 + require-directory@2.1.1: {} + require-from-string@2.0.2: {} require-like@0.1.2: {} + require-main-filename@2.0.0: {} + resolve-from@4.0.0: {} resolve-pkg-maps@1.0.0: @@ -7086,6 +7269,8 @@ snapshots: seroval@1.5.1: {} + set-blocking@2.0.0: {} + set-function-length@1.2.2: dependencies: define-data-property: 1.1.4 @@ -7533,6 +7718,8 @@ snapshots: is-weakmap: 2.0.2 is-weakset: 2.0.4 + which-module@2.0.1: {} + which-typed-array@1.1.20: dependencies: available-typed-arrays: 1.0.7 @@ -7554,16 +7741,43 @@ snapshots: word-wrap@1.2.5: {} + wrap-ansi@6.2.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi@9.0.2: dependencies: ansi-styles: 6.2.3 string-width: 7.2.0 strip-ansi: 7.2.0 + y18n@4.0.3: {} + yallist@3.1.1: {} yaml@2.8.3: {} + yargs-parser@18.1.3: + dependencies: + camelcase: 5.3.1 + decamelize: 1.2.0 + + yargs@15.4.1: + dependencies: + cliui: 6.0.0 + decamelize: 1.2.0 + find-up: 4.1.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + require-main-filename: 2.0.0 + set-blocking: 2.0.0 + string-width: 4.2.3 + which-module: 2.0.1 + y18n: 4.0.3 + yargs-parser: 18.1.3 + yocto-queue@0.1.0: {} zustand@5.0.12(@types/react@19.2.14)(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)): diff --git a/src/test-code/generate-qr-png.mjs b/src/test-code/generate-qr-png.mjs new file mode 100644 index 0000000..1b5d795 --- /dev/null +++ b/src/test-code/generate-qr-png.mjs @@ -0,0 +1,81 @@ +#!/usr/bin/env node + +import { createHash } from 'node:crypto'; +import { mkdir } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import QRCode from 'qrcode'; + +const outputDirectory = fileURLToPath(new URL('./generated-qr/', import.meta.url)); + +function normalizeUrl(input) { + const trimmedInput = input.trim(); + + if (/^https?:[^/]/iu.test(trimmedInput)) { + return trimmedInput.replace(/^(https?):/iu, '$1://'); + } + + if (!/^[a-z][a-z0-9+.-]*:/iu.test(trimmedInput)) { + return `https://${trimmedInput}`; + } + + return trimmedInput; +} + +function createFileName(urlText) { + const hash = createHash('sha256').update(urlText).digest('hex').slice(0, 8); + let readablePart = urlText; + + try { + const url = new URL(urlText); + readablePart = `${url.hostname}${url.pathname}`; + } catch { + readablePart = urlText; + } + + const slug = readablePart + .toLowerCase() + .replace(/[^a-z0-9]+/gu, '-') + .replace(/^-+|-+$/gu, '') + .slice(0, 72); + + return `${slug || 'qr-code'}-${hash}.png`; +} + +function printUsage() { + console.error('Usage: pnpm test-code '); + console.error('Alias: pnpm test-code: '); + console.error('Example: pnpm test-code https://naver.com'); +} + +async function generateQrPng(rawUrl) { + const urlText = normalizeUrl(rawUrl); + const filePath = path.join(outputDirectory, createFileName(urlText)); + + await mkdir(outputDirectory, { recursive: true }); + await QRCode.toFile(filePath, urlText, { + color: { + dark: '#000000', + light: '#ffffff', + }, + errorCorrectionLevel: 'M', + margin: 2, + scale: 10, + type: 'png', + }); + + console.log(`QR text: ${urlText}`); + console.log(`Saved: ${filePath}`); +} + +const urls = process.argv.slice(2).filter((argument) => argument.trim().length > 0); + +if (urls.length === 0) { + printUsage(); + process.exitCode = 1; +} else { + for (const url of urls) { + await generateQrPng(url); + } +} From 9168b34f81b48ffa9b8d284f810a5eea4fc9aa12 Mon Sep 17 00:00:00 2001 From: kimsubsub Date: Thu, 14 May 2026 19:16:38 +0900 Subject: [PATCH 04/27] =?UTF-8?q?fix:=20=EC=99=B8=EB=B6=80=20=EB=A7=81?= =?UTF-8?q?=ED=81=AC=20=EA=B2=80=EC=A6=9D=20=EB=B0=8F=20=EB=B3=B4=EC=95=88?= =?UTF-8?q?=20=EA=B0=90=EC=82=AC=20=ED=86=B5=EA=B3=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .secretlintignore | 1 + package.json | 1 + pnpm-lock.yaml | 9 +++-- .../lib/browser/openExternalLink.test.ts | 38 +++++++++++++++++++ src/shared/lib/browser/openExternalLink.ts | 9 ++++- 5 files changed, 53 insertions(+), 5 deletions(-) create mode 100644 src/shared/lib/browser/openExternalLink.test.ts diff --git a/.secretlintignore b/.secretlintignore index a6da4e9..65de6ac 100644 --- a/.secretlintignore +++ b/.secretlintignore @@ -6,3 +6,4 @@ build out .git package-lock.json +src/test-code/generated-qr/ diff --git a/package.json b/package.json index ef28690..dfae078 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "overrides": { "brace-expansion@^1.1.7": "^1.1.13", "brace-expansion@^5.0.2": "^5.0.5", + "fast-uri": "^3.1.2", "flatted": "^3.4.2", "lodash": "^4.18.1", "picomatch@^2.3.1": "^2.3.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 32adb4f..d86e180 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,6 +7,7 @@ settings: overrides: brace-expansion@^1.1.7: ^1.1.13 brace-expansion@^5.0.2: ^5.0.5 + fast-uri: ^3.1.2 flatted: ^3.4.2 lodash: ^4.18.1 picomatch@^2.3.1: ^2.3.2 @@ -2576,10 +2577,10 @@ packages: integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==, } - fast-uri@3.1.0: + fast-uri@3.1.2: resolution: { - integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==, + integrity: sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==, } fastq@1.20.1: @@ -5863,7 +5864,7 @@ snapshots: ajv@8.18.0: dependencies: fast-deep-equal: 3.1.3 - fast-uri: 3.1.0 + fast-uri: 3.1.2 json-schema-traverse: 1.0.0 require-from-string: 2.0.2 @@ -6485,7 +6486,7 @@ snapshots: fast-levenshtein@2.0.6: {} - fast-uri@3.1.0: {} + fast-uri@3.1.2: {} fastq@1.20.1: dependencies: diff --git a/src/shared/lib/browser/openExternalLink.test.ts b/src/shared/lib/browser/openExternalLink.test.ts new file mode 100644 index 0000000..aa9188f --- /dev/null +++ b/src/shared/lib/browser/openExternalLink.test.ts @@ -0,0 +1,38 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { openExternalLink } from './openExternalLink'; + +describe('openExternalLink', () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('opens safe http and https URLs with noreferrer protections', () => { + const open = vi.fn(); + + vi.stubGlobal('window', { open }); + + expect(openExternalLink('https://example.com/path')).toBe(true); + expect(open).toHaveBeenCalledWith('https://example.com/path', '_blank', 'noopener,noreferrer'); + }); + + it('blocks unsafe protocols before opening a new tab', () => { + const open = vi.fn(); + const scriptUrl = ['java', 'script:alert(1)'].join(''); + + vi.stubGlobal('window', { open }); + + expect(openExternalLink(scriptUrl)).toBe(false); + expect(open).not.toHaveBeenCalled(); + }); + + it('blocks URLs with embedded credentials', () => { + const open = vi.fn(); + const credentialUrl = ['https://user', 'pass@example.com'].join(':'); + + vi.stubGlobal('window', { open }); + + expect(openExternalLink(credentialUrl)).toBe(false); + expect(open).not.toHaveBeenCalled(); + }); +}); diff --git a/src/shared/lib/browser/openExternalLink.ts b/src/shared/lib/browser/openExternalLink.ts index d5b78d5..3cd6d5c 100644 --- a/src/shared/lib/browser/openExternalLink.ts +++ b/src/shared/lib/browser/openExternalLink.ts @@ -1,3 +1,10 @@ -export function openExternalLink(url: string): void { +import { isSafeExternalUrl } from '../security/isSafeExternalUrl'; + +export function openExternalLink(url: string): boolean { + if (!isSafeExternalUrl(url)) { + return false; + } + window.open(url, '_blank', 'noopener,noreferrer'); + return true; } From c570fcf3fc118f048ba2799d7d61cc1a239c0eb1 Mon Sep 17 00:00:00 2001 From: kimsubsub Date: Thu, 14 May 2026 20:26:48 +0900 Subject: [PATCH 05/27] =?UTF-8?q?feat:=20=EB=B9=84=20URL=20=EC=8A=A4?= =?UTF-8?q?=ED=82=A4=EB=A7=88=20=EB=AF=B8=EB=A6=AC=EB=B3=B4=EA=B8=B0=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/ResultNonUrl/ResultNonUrlPage.tsx | 60 ++-------------- .../constants/nonUrlActionText.ts | 40 ----------- .../styles/resultNonUrlPage.css.ts | 71 ------------------- 3 files changed, 6 insertions(+), 165 deletions(-) diff --git a/src/pages/ResultNonUrl/ResultNonUrlPage.tsx b/src/pages/ResultNonUrl/ResultNonUrlPage.tsx index 287bdfd..0fc2c11 100644 --- a/src/pages/ResultNonUrl/ResultNonUrlPage.tsx +++ b/src/pages/ResultNonUrl/ResultNonUrlPage.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState } from 'react'; +import { useState } from 'react'; import { ResultActionButtons } from '@/shared/component'; import { qrIconByTone } from '@/shared/icon/resultIcons'; @@ -7,48 +7,25 @@ import AppHeader from '@/shared/ui/app-header'; import ResultHero from '@/shared/ui/resultHero'; import { resultPageStyles } from '@/shared/ui/resultPage'; -import { nonUrlActionPreviewItems, resolveNonUrlSectionCopy } from './constants/nonUrlActionText'; +import { resolveNonUrlSectionCopy } from './constants/nonUrlActionText'; import { useResultNonUrlPage } from './hooks/useResultNonUrlPage'; import { resolveNonUrlActionExecution } from './lib/resolveNonUrlActionExecution'; import * as styles from './styles/resultNonUrlPage.css'; import DetectedNonUrlActionSection from './ui/DetectedNonUrlActionSection'; -import type { NonUrlActionType } from './types/resultNonUrlPage.types'; - export default function ResultNonUrlPage() { const { handleRescan, handleShareResult, resultNonUrlPageData } = useResultNonUrlPage(); - const [selectedPreviewActionType, setSelectedPreviewActionType] = - useState(null); const [executionFeedbackMessage, setExecutionFeedbackMessage] = useState(null); - const selectedPreviewItem = useMemo( - () => - selectedPreviewActionType - ? (nonUrlActionPreviewItems.find((item) => item.actionType === selectedPreviewActionType) ?? - null) - : null, - [selectedPreviewActionType], - ); - if (!resultNonUrlPageData) { return null; } - const displayedActionType = - selectedPreviewItem?.actionType ?? resultNonUrlPageData.detectedActionType; - const displayedActionLabel = - selectedPreviewItem?.label ?? - nonUrlActionPreviewItems.find((item) => item.actionType === displayedActionType)?.label ?? - displayedActionType; + const displayedActionType = resultNonUrlPageData.detectedActionType; + const displayedActionLabel = resultNonUrlPageData.detectedActionLabel; const displayedSectionCopy = resolveNonUrlSectionCopy(displayedActionType); - const displayedTargetValue = - displayedActionType === resultNonUrlPageData.detectedActionType - ? resultNonUrlPageData.targetValue - : undefined; - const isExecutable = - displayedActionType === resultNonUrlPageData.detectedActionType && - displayedTargetValue !== undefined && - displayedTargetValue !== null; + const displayedTargetValue = resultNonUrlPageData.targetValue; + const isExecutable = displayedTargetValue !== undefined && displayedTargetValue !== null; const handleExecuteAction = () => { if (!isExecutable) { @@ -115,31 +92,6 @@ export default function ResultNonUrlPage() { void handleShareResult(); }} /> - -
-

전체 URL 스키마 타입 보기

- -
- {nonUrlActionPreviewItems.map((previewItem) => ( - - ))} -
-
diff --git a/src/pages/ResultNonUrl/constants/nonUrlActionText.ts b/src/pages/ResultNonUrl/constants/nonUrlActionText.ts index 9f2079c..442251b 100644 --- a/src/pages/ResultNonUrl/constants/nonUrlActionText.ts +++ b/src/pages/ResultNonUrl/constants/nonUrlActionText.ts @@ -6,15 +6,9 @@ type NonUrlActionCatalogItem = NonUrlSectionCopy & { buildDescription: (targetValue?: string) => string; caution: string; englishLabel: string; - previewLabel: string; title: string; }; -type NonUrlActionPreviewItem = { - actionType: NonUrlActionType; - label: string; -}; - type ResolvedNonUrlActionContent = { caution: string; description: string; @@ -22,21 +16,6 @@ type ResolvedNonUrlActionContent = { title: string; }; -const nonUrlActionTypeOrder: NonUrlActionType[] = [ - 'WEB', - 'SHORT_URL', - 'OTP', - 'CRYPTO', - 'SMS', - 'WIFI', - 'CONTACT', - 'DEEP_LINK', - 'TEL', - 'EMAIL', - 'APP_STORE', - 'OTHER', -]; - const nonUrlActionTextMap: Record = { WEB: { buildDescription: (targetValue) => @@ -46,7 +25,6 @@ const nonUrlActionTextMap: Record = { caution: '공식 사이트와 비슷한 주소를 사용한 피싱 페이지일 수 있습니다. 로그인 정보, 카드 정보, 계정 인증번호 같은 민감한 정보를 입력하기 전에는 도메인을 먼저 확인하세요.', englishLabel: 'WEB LINK', - previewLabel: '웹', sectionDescription: '일반적인 웹사이트 주소로 연결되는 QR 코드입니다. 브라우저에서 페이지가 열리며 로그인, 결제, 다운로드, 개인정보 입력 같은 후속 행동이 이어질 수 있으니 접속 전에 도메인과 목적을 먼저 확인하세요.', sectionTitle: '탐지된 웹사이트 이동 정보', @@ -60,7 +38,6 @@ const nonUrlActionTextMap: Record = { caution: '단축 URL은 최종 목적지가 숨겨져 있어 바로 신뢰하기 어렵습니다. 출처가 명확하지 않다면 즉시 열지 말고, 가능한 경우 목적지를 먼저 확인하세요.', englishLabel: 'SHORT URL', - previewLabel: '단축 URL', sectionDescription: 'bit.ly 등 단축 URL 서비스로 연결되는 QR 코드입니다. 실제 최종 도착 주소가 가려져 있을 수 있어서, 사용자는 스캔 전에는 어디로 이동하는지 정확히 알기 어렵습니다.', sectionTitle: '탐지된 단축 URL 정보', @@ -74,7 +51,6 @@ const nonUrlActionTextMap: Record = { caution: '본인이 직접 등록하려던 계정이 아니라면 추가하지 마세요. 잘못된 OTP를 등록하면 계정 관리 과정에 혼선이 생기거나, 공격자가 안내하는 인증 절차에 끌려갈 수 있습니다.', englishLabel: 'OTP SETUP', - previewLabel: 'OTP', sectionDescription: '2단계 인증용 일회용 비밀번호 설정 정보가 포함된 QR 코드입니다. 인증 앱에 새 계정이 추가될 수 있으므로 어떤 서비스의 계정을 등록하는지 먼저 확인해야 합니다.', sectionTitle: '탐지된 OTP 등록 정보', @@ -88,7 +64,6 @@ const nonUrlActionTextMap: Record = { caution: '가상자산 전송은 되돌리기 어렵습니다. 주소, 네트워크, 금액, 요청한 상대가 모두 맞는지 직접 확인하기 전에는 승인하지 마세요.', englishLabel: 'CRYPTO PAYMENT', - previewLabel: '가상자산', sectionDescription: '가상자산 지갑 주소 및 송금 요청을 담은 QR 코드입니다. 스캔 후 지갑 앱이 열리거나 송금 화면이 표시될 수 있으며, 주소와 금액이 자동으로 채워질 수 있습니다.', sectionTitle: '탐지된 가상자산 지갑 정보', @@ -102,7 +77,6 @@ const nonUrlActionTextMap: Record = { caution: '자동 입력된 번호나 문구를 그대로 보내면 비용 발생, 인증 우회 시도 대응, 스미싱 회신으로 이어질 수 있습니다. 전송 전 수신자와 본문을 모두 확인하세요.', englishLabel: 'SMS ACTION', - previewLabel: '문자', sectionDescription: '문자 메시지 작성 또는 발송 화면을 여는 QR 코드입니다. 수신자 번호와 본문이 자동 입력될 수 있어서, 사용자가 의도하지 않은 메시지를 보내도록 유도할 수 있습니다.', sectionTitle: '탐지된 문자 발송 정보', @@ -116,7 +90,6 @@ const nonUrlActionTextMap: Record = { caution: '모르는 Wi-Fi에 연결하면 통신 내용 가로채기, 가짜 인증 페이지, 악성 설정 유도로 이어질 수 있습니다. 네트워크 이름과 제공 주체를 먼저 확인하세요.', englishLabel: 'WIFI CONFIGURATION', - previewLabel: 'Wi-Fi', sectionDescription: '와이파이 네트워크에 자동 연결할 수 있는 설정 정보가 담긴 QR 코드입니다. SSID, 보안 방식, 비밀번호가 포함될 수 있으므로 신뢰할 수 있는 네트워크인지 먼저 확인한 뒤 연결해야 합니다.', sectionTitle: '탐지된 Wi-Fi 연결 정보', @@ -130,7 +103,6 @@ const nonUrlActionTextMap: Record = { caution: '저장 전 이름, 전화번호, 이메일, 회사명이 실제로 맞는지 확인하세요. 허위 연락처를 저장하면 이후 사칭 연락을 더 쉽게 믿게 될 수 있습니다.', englishLabel: 'CONTACT VCARD', - previewLabel: '연락처', sectionDescription: '연락처(vCard) 정보를 담은 QR 코드입니다. 이름, 전화번호, 이메일, 회사명 등이 주소록에 저장될 수 있으므로 저장 전에 정보의 진위를 확인하는 것이 좋습니다.', sectionTitle: '탐지된 연락처 저장 정보', @@ -144,7 +116,6 @@ const nonUrlActionTextMap: Record = { caution: '딥링크는 송금, 로그인, 친구추가, 결제 같은 민감한 화면으로 바로 연결될 수 있습니다. 어떤 앱이 열리는지와 왜 그 화면으로 가는지를 먼저 확인하세요.', englishLabel: 'APP DEEP LINK', - previewLabel: '딥링크', sectionDescription: '특정 앱의 특정 화면을 바로 여는 딥링크입니다. 예를 들어 instagram://, kakaotalk://, kakaopay/money/remit 같은 형식으로 앱 내부 기능까지 직접 열 수 있습니다.', sectionTitle: '탐지된 앱 딥링크 정보', @@ -158,7 +129,6 @@ const nonUrlActionTextMap: Record = { caution: '모르는 번호나 고가 요금 번호로 연결될 수 있습니다. 발신 전에 번호와 안내한 주체가 실제로 맞는지 확인하세요.', englishLabel: 'PHONE CALL', - previewLabel: '전화', sectionDescription: '전화 걸기 화면으로 이동하는 QR 코드입니다. 번호가 자동 입력될 수 있어서 사용자가 의도하지 않은 상대에게 바로 발신하도록 유도할 수 있습니다.', sectionTitle: '탐지된 전화 걸기 정보', @@ -172,7 +142,6 @@ const nonUrlActionTextMap: Record = { caution: '자동 입력된 수신자, 제목, 본문을 그대로 신뢰하지 마세요. 보내기 전에 이메일 주소와 요청 내용이 적절한지 다시 확인하세요.', englishLabel: 'EMAIL ACTION', - previewLabel: '이메일', sectionDescription: '이메일 작성 화면을 여는 QR 코드입니다. 받는 사람, 제목, 본문이 미리 입력될 수 있어 사용자가 특정 주소로 바로 메일을 보내도록 유도할 수 있습니다.', sectionTitle: '탐지된 이메일 작성 정보', @@ -186,7 +155,6 @@ const nonUrlActionTextMap: Record = { caution: '앱 이름이 비슷하다고 같은 앱은 아닙니다. 설치 전 개발사명, 다운로드 수, 리뷰, 권한 요청 내용을 확인하세요.', englishLabel: 'APP STORE LINK', - previewLabel: '앱 마켓', sectionDescription: '앱 마켓(Play Store, App Store)의 설치 또는 업데이트 페이지로 이동하는 QR 코드입니다. 검색 없이 바로 앱 페이지로 이동하므로 개발사와 앱 정보를 꼭 확인해야 합니다.', sectionTitle: '탐지된 앱 마켓 이동 정보', @@ -200,7 +168,6 @@ const nonUrlActionTextMap: Record = { caution: '정확한 동작을 알 수 없는 QR 코드는 자동 실행하지 않는 편이 안전합니다. 필요한 경우 내용을 먼저 검토하고, 신뢰할 수 있는 출처인지 확인한 뒤 판단하세요.', englishLabel: 'OTHER / UNKNOWN', - previewLabel: '기타', sectionDescription: '기타 텍스트 및 미분류 타입입니다. 현재 규칙으로는 동작을 명확히 설명하기 어려우며, 특정 앱이나 환경에서만 해석되는 값일 수 있으니 바로 실행하지 않는 것이 안전합니다.', sectionTitle: '탐지된 기타 스키마 정보', @@ -208,13 +175,6 @@ const nonUrlActionTextMap: Record = { }, }; -export const nonUrlActionPreviewItems: NonUrlActionPreviewItem[] = nonUrlActionTypeOrder.map( - (actionType) => ({ - actionType, - label: nonUrlActionTextMap[actionType].previewLabel, - }), -); - export function resolveNonUrlSectionCopy(actionType: NonUrlActionType): NonUrlSectionCopy { const catalogItem = nonUrlActionTextMap[actionType]; diff --git a/src/pages/ResultNonUrl/styles/resultNonUrlPage.css.ts b/src/pages/ResultNonUrl/styles/resultNonUrlPage.css.ts index 86e297d..d96c1cc 100644 --- a/src/pages/ResultNonUrl/styles/resultNonUrlPage.css.ts +++ b/src/pages/ResultNonUrl/styles/resultNonUrlPage.css.ts @@ -15,11 +15,7 @@ const nonUrlPalette = { detailText: '#B28300', executionButtonShadow: '0 14px 32px rgba(242, 223, 13, 0.18)', focusRing: 'rgba(0, 106, 228, 0.24)', - previewActiveText: '#3D3200', - previewBackground: '#FFFBEF', - previewBorder: 'rgba(242, 223, 13, 0.28)', previewText: '#7D6200', - previewValueText: '#6F5A00', sectionBackground: '#FFFDF6', sectionBorder: 'rgba(242, 223, 13, 0.45)', sectionNumberBackground: '#FFF1B8', @@ -225,70 +221,3 @@ export const executionFeedback = style({ lineHeight: 1.6, textAlign: 'center', }); - -export const previewSection = style({ - display: 'grid', - gap: vars.spacing.sm, - padding: vars.spacing.md, - borderRadius: vars.radius.lg, - border: `1px solid ${nonUrlPalette.previewBorder}`, - backgroundColor: nonUrlPalette.previewBackground, -}); - -export const previewTitle = style({ - margin: 0, - color: nonUrlPalette.previewText, - fontSize: vars.font.size.md, - fontWeight: vars.font.weight.semibold, - lineHeight: 1.5, -}); - -export const previewButtonList = style({ - display: 'flex', - flexWrap: 'wrap', - gap: vars.spacing.sm, -}); - -export const previewButton = style({ - border: `1px solid ${nonUrlPalette.sectionBorder}`, - borderRadius: '999px', - backgroundColor: vars.colors.white, - color: nonUrlPalette.previewValueText, - padding: '8px 14px', - fontSize: vars.font.size.sm, - fontWeight: vars.font.weight.medium, - lineHeight: 1.2, - cursor: 'pointer', - selectors: { - '&:focus-visible': { - outline: `2px solid ${vars.colors.main}`, - outlineOffset: '2px', - boxShadow: `0 0 0 4px ${nonUrlPalette.focusRing}`, - }, - '&:disabled': { - opacity: 0.55, - cursor: 'not-allowed', - pointerEvents: 'none', - boxShadow: 'none', - }, - }, -}); - -export const previewButtonActive = style({ - backgroundColor: vars.colors.warning, - borderColor: nonUrlPalette.accentEnd, - color: nonUrlPalette.previewActiveText, - selectors: { - '&:focus-visible': { - outline: `2px solid ${vars.colors.main}`, - outlineOffset: '2px', - boxShadow: `0 0 0 4px ${nonUrlPalette.focusRing}`, - }, - '&:disabled': { - opacity: 0.55, - cursor: 'not-allowed', - pointerEvents: 'none', - boxShadow: 'none', - }, - }, -}); From be7b014320746de132394b79574c9ff1f16c0755 Mon Sep 17 00:00:00 2001 From: kimsubsub Date: Thu, 14 May 2026 20:26:59 +0900 Subject: [PATCH 06/27] =?UTF-8?q?fix:=20=EA=B2=B0=EA=B3=BC=20=ED=99=94?= =?UTF-8?q?=EB=A9=B4=20=EC=A0=90=EC=88=98=20=EA=B8=B0=EB=B0=98=20=EC=9C=84?= =?UTF-8?q?=ED=97=98=EB=8F=84=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/Result/ResultPage.tsx | 7 +++--- .../api/createResultPageFetcher.test.ts | 2 ++ .../Result/api/createResultPageFetcher.ts | 1 + src/pages/Result/hooks/useResultPage.ts | 12 +++++----- src/pages/Result/lib/toResultPageData.test.ts | 22 +++++++++++++++++++ src/pages/Result/lib/toResultPageData.ts | 1 + src/pages/Result/types/resultPage.types.ts | 3 +++ src/pages/Result/ui/ResultStatusPage.tsx | 1 + 8 files changed, 41 insertions(+), 8 deletions(-) diff --git a/src/pages/Result/ResultPage.tsx b/src/pages/Result/ResultPage.tsx index ee3a3bb..a72b64f 100644 --- a/src/pages/Result/ResultPage.tsx +++ b/src/pages/Result/ResultPage.tsx @@ -111,14 +111,15 @@ export default function ResultPage({ tone }: ResultPageProps) { handleVisit, resultData, } = useResultPage(tone); - const config = resultViewConfig[tone]; - const isDetailUnavailable = resultData?.detailUnavailable === true; - const resolvedTone: ResultTone = isDetailUnavailable ? 'warning' : tone; if (!resultData) { return null; } + const isDetailUnavailable = resultData.detailUnavailable === true; + const resolvedTone: ResultTone = isDetailUnavailable ? 'warning' : resultData.riskLevel; + const config = resultViewConfig[resolvedTone]; + return ( { const { fetchResultPageData } = createResultPageFetcher('safe'); await expect(fetchResultPageData()).resolves.toMatchObject({ + riskLevel: 'safe', siteName: 'http://naver-login-check.xyz', siteUrl: 'http://naver-login-check.xyz', trustScore: 23, @@ -58,6 +59,7 @@ describe('createResultPageFetcher', () => { await expect(fetchResultPageData()).resolves.toMatchObject({ detailUnavailable: true, + riskLevel: 'warning', siteMeta: DETAIL_UNAVAILABLE_MESSAGE, siteName: 'https://www.daum.net/', siteUrl: 'https://www.daum.net/', diff --git a/src/pages/Result/api/createResultPageFetcher.ts b/src/pages/Result/api/createResultPageFetcher.ts index c6bc123..f90632b 100644 --- a/src/pages/Result/api/createResultPageFetcher.ts +++ b/src/pages/Result/api/createResultPageFetcher.ts @@ -36,6 +36,7 @@ function createDetailUnavailableResultPageData(url: string): ResultPageData { return { detailUnavailable: true, previewUrl: url, + riskLevel: 'warning', siteMeta: DETAIL_UNAVAILABLE_MESSAGE, siteName: url, siteUrl: url, diff --git a/src/pages/Result/hooks/useResultPage.ts b/src/pages/Result/hooks/useResultPage.ts index 4892dc5..3a8394a 100644 --- a/src/pages/Result/hooks/useResultPage.ts +++ b/src/pages/Result/hooks/useResultPage.ts @@ -44,7 +44,9 @@ export function useResultPage(tone: ResultTone): UseResultPageReturn { return; } - if (tone === 'critical') { + const resolvedTone = resultData.riskLevel; + + if (resolvedTone === 'critical') { modal.error({ centered: true, content: '이 URL은 악성 위협이 높아 접속을 차단했습니다.', @@ -54,7 +56,7 @@ export function useResultPage(tone: ResultTone): UseResultPageReturn { return; } - if (tone === 'warning') { + if (resolvedTone === 'warning') { modal.confirm({ cancelText: '취소', centered: true, @@ -69,10 +71,10 @@ export function useResultPage(tone: ResultTone): UseResultPageReturn { } openExternalLink(resultData.visitUrl); - }, [modal, resultData, tone]); + }, [modal, resultData]); const handleReport = useCallback(() => { - if (tone !== 'critical') { + if (resultData?.riskLevel !== 'critical') { return; } @@ -87,7 +89,7 @@ export function useResultPage(tone: ResultTone): UseResultPageReturn { message.success('신고가 접수되었습니다. 빠르게 확인하겠습니다.'); }, }); - }, [message, modal, tone]); + }, [message, modal, resultData?.riskLevel]); return { handleReport, diff --git a/src/pages/Result/lib/toResultPageData.test.ts b/src/pages/Result/lib/toResultPageData.test.ts index e7ff4c5..4d077a2 100644 --- a/src/pages/Result/lib/toResultPageData.test.ts +++ b/src/pages/Result/lib/toResultPageData.test.ts @@ -30,6 +30,28 @@ describe('toResultPageData', () => { const resultPageData = toResultPageData(session, 'safe'); + expect(resultPageData.riskLevel).toBe('safe'); expect(resultPageData.siteMeta).toBe('SSL 인증서 유효하지 않음 · 발급자 정보 없음'); }); + + it('uses score-derived risk level even when fallback route tone is lower', () => { + const session: ScanSessionSnapshot = { + analysisDetail: { + riskLevel: 'safe', + score: 85, + }, + decodedUrl: 'https://testsafebrowsing.appspot.com/s/phishing.html', + finalResult: null, + historySelection: null, + isUrl: true, + riskLevel: null, + scanResponse: null, + schemeType: 'WEB', + }; + + const resultPageData = toResultPageData(session, 'warning'); + + expect(resultPageData.riskLevel).toBe('critical'); + expect(resultPageData.trustScore).toBe(85); + }); }); diff --git a/src/pages/Result/lib/toResultPageData.ts b/src/pages/Result/lib/toResultPageData.ts index 17f5304..32ea112 100644 --- a/src/pages/Result/lib/toResultPageData.ts +++ b/src/pages/Result/lib/toResultPageData.ts @@ -156,6 +156,7 @@ export function toResultPageData( return { detailUnavailable: false, previewUrl: resolvedPreviewUrl, + riskLevel: resolvedTone, siteMeta: buildSiteMeta(sources), siteName: resolvedOriginalUrl, siteUrl: resolvedFinalUrl, diff --git a/src/pages/Result/types/resultPage.types.ts b/src/pages/Result/types/resultPage.types.ts index f1ca30b..933f611 100644 --- a/src/pages/Result/types/resultPage.types.ts +++ b/src/pages/Result/types/resultPage.types.ts @@ -1,6 +1,9 @@ +import type { ResultTone } from '@/shared/types/resultTone'; + export type ResultPageData = { detailUnavailable?: boolean; previewUrl: string; + riskLevel: ResultTone; siteMeta: string; siteName: string; siteUrl: string; diff --git a/src/pages/Result/ui/ResultStatusPage.tsx b/src/pages/Result/ui/ResultStatusPage.tsx index da11057..f784327 100644 --- a/src/pages/Result/ui/ResultStatusPage.tsx +++ b/src/pages/Result/ui/ResultStatusPage.tsx @@ -23,6 +23,7 @@ type ResultActionOptions = Pick< >; type ResultStatusPageData = { + riskLevel: ResultTone; siteMeta: string; siteName: string; siteUrl: string; From f854324a9e9d20751a0acf258102ed55f01b60c2 Mon Sep 17 00:00:00 2001 From: kimsubsub Date: Thu, 14 May 2026 20:27:10 +0900 Subject: [PATCH 07/27] =?UTF-8?q?fix:=20=EC=8A=A4=EC=BA=94=20=EC=9D=B4?= =?UTF-8?q?=EB=A0=A5=20=EC=A0=90=EC=88=98=20=EA=B8=B0=EB=B0=98=20=EC=9C=84?= =?UTF-8?q?=ED=97=98=EB=8F=84=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../scan-history/lib/historyItemAccess.test.ts | 11 +++++++++++ src/features/scan-history/lib/historyItemAccess.ts | 5 ++--- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/src/features/scan-history/lib/historyItemAccess.test.ts b/src/features/scan-history/lib/historyItemAccess.test.ts index 1b9faed..c4405cb 100644 --- a/src/features/scan-history/lib/historyItemAccess.test.ts +++ b/src/features/scan-history/lib/historyItemAccess.test.ts @@ -30,6 +30,17 @@ describe('historyItemAccess', () => { ); }); + it('uses score before riskLevel for history status labels', () => { + expect( + pickHistoryRiskLevel({ + riskLevel: 'warning', + scannedAt: '2026-05-14 10:20:30', + score: 85, + typeInfo: 'https://testsafebrowsing.appspot.com/s/phishing.html', + }), + ).toBe('critical'); + }); + it('sorts backend timestamp labels by recency', () => { expect(resolveHistoryTimestamp('2026-04-21 02:57:53')).toBeGreaterThan( resolveHistoryTimestamp('2026-04-21 02:50:44'), diff --git a/src/features/scan-history/lib/historyItemAccess.ts b/src/features/scan-history/lib/historyItemAccess.ts index fb7150a..6d642bc 100644 --- a/src/features/scan-history/lib/historyItemAccess.ts +++ b/src/features/scan-history/lib/historyItemAccess.ts @@ -1,10 +1,9 @@ import { pickBoolean, pickString } from '@/shared/api/responseAccess/payloadAccess'; -import { normalizeRiskLevel } from '@/shared/api/risk/normalizeRiskLevel'; +import { resolveResultToneFromSource } from '@/shared/api/risk/resolveResultTone'; import type { ResultTone } from '@/shared/types/resultTone'; const historyIdKeys = ['id', 'scanId', 'scan_id', 'uuid', 'scanUuid', 'scan_uuid']; const historyScannedAtKeys = ['scannedAt', 'scanned_at', 'createdAt', 'created_at']; -const historyRiskLevelKeys = ['riskLevel', 'risk_level', 'status', 'result']; const historySchemeTypeKeys = ['schemeType', 'scheme_type']; const historyTargetValueKeys = [ 'typeInfo', @@ -32,7 +31,7 @@ export function pickHistoryScannedAt(source: unknown): string | null { } export function pickHistoryRiskLevel(source: unknown): ResultTone | null { - return normalizeRiskLevel(pickString(source, historyRiskLevelKeys)); + return resolveResultToneFromSource(source); } export function pickHistorySchemeType(source: unknown): string | null { From db42beda1e70f8526ab052f79a3fceb1cb425e3a Mon Sep 17 00:00:00 2001 From: kimsubsub Date: Thu, 14 May 2026 20:38:33 +0900 Subject: [PATCH 08/27] =?UTF-8?q?fix:=20=EB=B9=84=20URL=20=EA=B2=B0?= =?UTF-8?q?=EA=B3=BC=20=EB=9D=BC=EB=B2=A8=20=ED=83=80=EC=9E=85=20=EC=98=A4?= =?UTF-8?q?=EB=A5=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/ResultNonUrl/ResultNonUrlPage.tsx | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/src/pages/ResultNonUrl/ResultNonUrlPage.tsx b/src/pages/ResultNonUrl/ResultNonUrlPage.tsx index 0fc2c11..868edee 100644 --- a/src/pages/ResultNonUrl/ResultNonUrlPage.tsx +++ b/src/pages/ResultNonUrl/ResultNonUrlPage.tsx @@ -13,6 +13,23 @@ import { resolveNonUrlActionExecution } from './lib/resolveNonUrlActionExecution import * as styles from './styles/resultNonUrlPage.css'; import DetectedNonUrlActionSection from './ui/DetectedNonUrlActionSection'; +import type { NonUrlActionType } from './types/resultNonUrlPage.types'; + +const nonUrlActionLabelByType: Record = { + APP_STORE: '앱 마켓', + CONTACT: '연락처', + CRYPTO: '가상자산', + DEEP_LINK: '딥링크', + EMAIL: '이메일', + OTHER: '기타', + OTP: 'OTP', + SHORT_URL: '단축 URL', + SMS: '문자', + TEL: '전화', + WEB: '웹', + WIFI: 'Wi-Fi', +}; + export default function ResultNonUrlPage() { const { handleRescan, handleShareResult, resultNonUrlPageData } = useResultNonUrlPage(); const [executionFeedbackMessage, setExecutionFeedbackMessage] = useState(null); @@ -22,7 +39,7 @@ export default function ResultNonUrlPage() { } const displayedActionType = resultNonUrlPageData.detectedActionType; - const displayedActionLabel = resultNonUrlPageData.detectedActionLabel; + const displayedActionLabel = nonUrlActionLabelByType[displayedActionType]; const displayedSectionCopy = resolveNonUrlSectionCopy(displayedActionType); const displayedTargetValue = resultNonUrlPageData.targetValue; const isExecutable = displayedTargetValue !== undefined && displayedTargetValue !== null; From c24e58618e03b2a2918612e35de685e9787e8ba5 Mon Sep 17 00:00:00 2001 From: kimsubsub Date: Thu, 14 May 2026 20:38:41 +0900 Subject: [PATCH 09/27] =?UTF-8?q?chore:=20=ED=83=80=EC=9E=85=20=EC=B2=B4?= =?UTF-8?q?=ED=81=AC=20CI=20=EB=8B=A8=EA=B3=84=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/ci.yml | 3 +++ package.json | 1 + 2 files changed, 4 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 689b721..685fb79 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,5 +38,8 @@ jobs: - name: Run tests run: pnpm run test + - name: Run TypeScript typecheck + run: pnpm run typecheck + - name: Build application run: pnpm run build diff --git a/package.json b/package.json index dfae078..71dbdc8 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "format": "prettier --check .", "format:write": "prettier --write .", "test": "vitest run", + "typecheck": "tsc --noEmit", "test-code": "node src/test-code/generate-qr-png.mjs", "test-code:": "node src/test-code/generate-qr-png.mjs", "security:check": "secretlint \"**/*\" && pnpm audit --audit-level high" From e856ff45d1a7d7126193f7d16c94db28f20db3a8 Mon Sep 17 00:00:00 2001 From: kimsubsub Date: Thu, 14 May 2026 20:38:49 +0900 Subject: [PATCH 10/27] =?UTF-8?q?docs:=20=EB=A6=AC=ED=8C=A9=ED=86=A0?= =?UTF-8?q?=EB=A7=81=20=EB=A1=9C=EB=93=9C=EB=A7=B5=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/refactor-roadmap.md | 49 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 docs/refactor-roadmap.md diff --git a/docs/refactor-roadmap.md b/docs/refactor-roadmap.md new file mode 100644 index 0000000..62ee10f --- /dev/null +++ b/docs/refactor-roadmap.md @@ -0,0 +1,49 @@ +# Refactor Roadmap + +## 현재 기준선 + +- 브랜치: `feat/42` +- 테스트: `pnpm test` 통과 +- 린트: `pnpm lint` 통과 +- 보안 점검: `pnpm security:check` 통과 +- 타입 체크: `pnpm typecheck` 통과 +- 빌드: `pnpm build` 통과 + +## 관찰 결과 + +- `vite build`만으로는 TypeScript 타입 오류가 잡히지 않는다. 실제로 `ResultNonUrlPage`의 존재하지 않는 필드 접근이 `tsc --noEmit`에서만 발견됐다. +- 백엔드 응답 정규화가 `scanSessionStore`, `toResultPageData`, `toReportPageData`, `historyItemAccess`, `toResultNonUrlPageData`에 나뉘어 있다. 위험도/URL/스키마 판정이 계속 불일치할 수 있는 구조다. +- 외부 HTTP 링크는 `openExternalLink`와 `isSafeExternalUrl`에서 제한하고 있지만, 비 URL 결과 실행은 `tel:`, `sms:`, `mailto:`, 앱 딥링크를 직접 실행한다. 허용 스킴과 사용자 확인 정책을 더 명확히 해야 한다. +- `scanSessionStore`가 URL, 위험도, 스키마, 분석 응답 정규화를 함께 담당한다. 장기적으로는 저장소는 상태 보관에 집중하고, 정규화는 별도 도메인 유틸로 분리하는 편이 안전하다. +- 긴 파일이 많다. 특히 `ReportPage`, `toReportPageData`, `useQRScanPage`, `useLoadingPage`, `resolveNonUrlActionExecution`은 변경 위험이 높고 테스트를 먼저 보강한 뒤 분리하는 것이 좋다. +- 빌드 결과에서 `antd`와 `react` 청크가 크다. 성능 개선은 먼저 라우트 단위 lazy loading과 실제 사용자 진입 경로 기준으로 접근하는 것이 좋다. +- 일부 문서/문구 파일은 인코딩이 깨져 보인다. 기능 리팩토링과 분리해서 문구/인코딩 정리 커밋으로 다루는 것이 안전하다. + +## 우선순위 + +| 우선순위 | 영역 | 목표 | 권장 커밋 단위 | +| -------- | ------------- | ------------------------------------------------------------------------- | ------------------------------------------------- | +| P0 | CI 안정성 | 타입 오류가 PR에서 반드시 잡히게 한다. | `typecheck` 스크립트와 CI 단계 추가 | +| P1 | 보안 | 비 URL 실행 스킴을 허용 목록으로 제한하고 위험 스킴은 확인 모달을 거친다. | 실행 정책 유틸 분리, 테스트 추가 | +| P1 | 데이터 정규화 | URL, 위험도, 스키마 판정을 하나의 공통 변환 계층으로 모은다. | `scan-result-normalizer` 유틸 추가, 페이지별 적용 | +| P1 | 상태 관리 | `scanSessionStore`에서 파싱 로직을 걷어내고 상태 저장 책임만 남긴다. | store 내부 정규화 함수 외부화 | +| P2 | 로딩/SSE | SSE 최종 결과, 상세 조회 폴링, 라우팅 전환을 더 작은 함수로 분리한다. | `useLoadingPage` 분리 및 테스트 보강 | +| P2 | 성능 | 라우트 단위 lazy loading과 무거운 청크 확인을 적용한다. | 라우터 lazy import, 빌드 크기 비교 | +| P2 | 리포트 페이지 | `ReportPage`와 `toReportPageData`를 섹션 단위로 쪼갠다. | URL/평판/서버정보 빌더 분리 | +| P3 | 문구/인코딩 | 깨진 문구와 문서 인코딩을 정리한다. | 문구 파일만 별도 수정 | + +## 추천 진행 순서 + +1. P0 CI 안정성 보강을 먼저 끝낸다. +2. 비 URL 실행 보안 정책을 정리한다. +3. 백엔드 응답 정규화 계층을 만들고 결과/리포트/이력에 순차 적용한다. +4. 상태 저장소에서 정규화 책임을 분리한다. +5. 라우트 lazy loading과 번들 크기 개선을 진행한다. +6. 리포트/QR 스캔/로딩 페이지의 큰 파일을 테스트와 함께 분리한다. + +## 작업 원칙 + +- 한 커밋은 한 책임만 가진다. +- 리팩토링 전에는 해당 영역 테스트를 먼저 추가하거나 기존 테스트를 보강한다. +- 보안 정책 변경은 성공 케이스와 차단 케이스를 모두 테스트한다. +- 성능 개선은 빌드 결과나 런타임 측정값을 남긴다. From d58b082bd815eb585e000d8d7d9a3d56fdf064b3 Mon Sep 17 00:00:00 2001 From: kimsubsub Date: Thu, 14 May 2026 20:46:23 +0900 Subject: [PATCH 11/27] =?UTF-8?q?fix:=20=EB=B9=84=20URL=20=EC=8B=A4?= =?UTF-8?q?=ED=96=89=20=EC=8A=A4=ED=82=B4=20=EB=B3=B4=EC=95=88=20=EA=B0=95?= =?UTF-8?q?=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/ResultNonUrl/ResultNonUrlPage.tsx | 30 +++- .../lib/resolveNonUrlActionExecution.test.ts | 77 +++++++++ .../lib/resolveNonUrlActionExecution.ts | 160 +++++++++++++++--- 3 files changed, 236 insertions(+), 31 deletions(-) create mode 100644 src/pages/ResultNonUrl/lib/resolveNonUrlActionExecution.test.ts diff --git a/src/pages/ResultNonUrl/ResultNonUrlPage.tsx b/src/pages/ResultNonUrl/ResultNonUrlPage.tsx index 868edee..94b43ed 100644 --- a/src/pages/ResultNonUrl/ResultNonUrlPage.tsx +++ b/src/pages/ResultNonUrl/ResultNonUrlPage.tsx @@ -1,3 +1,4 @@ +import { App } from 'antd'; import { useState } from 'react'; import { ResultActionButtons } from '@/shared/component'; @@ -31,6 +32,7 @@ const nonUrlActionLabelByType: Record = { }; export default function ResultNonUrlPage() { + const { modal } = App.useApp(); const { handleRescan, handleShareResult, resultNonUrlPageData } = useResultNonUrlPage(); const [executionFeedbackMessage, setExecutionFeedbackMessage] = useState(null); @@ -50,16 +52,32 @@ export default function ResultNonUrlPage() { } const executionPlan = resolveNonUrlActionExecution(displayedActionType, displayedTargetValue); - setExecutionFeedbackMessage(executionPlan.message); - if (executionPlan.kind === 'open') { - openExternalLink(executionPlan.url); + if (executionPlan.kind === 'unsupported') { + setExecutionFeedbackMessage(executionPlan.message); return; } - if (executionPlan.kind === 'navigate') { - window.location.href = executionPlan.href; - } + modal.confirm({ + cancelText: '취소', + centered: true, + content: executionPlan.confirmationContent, + okText: '실행', + onOk: () => { + if (executionPlan.kind === 'open') { + const opened = openExternalLink(executionPlan.url); + + setExecutionFeedbackMessage( + opened ? executionPlan.message : '안전하지 않은 링크라 실행하지 않았습니다.', + ); + return; + } + + setExecutionFeedbackMessage(executionPlan.message); + window.location.href = executionPlan.href; + }, + title: executionPlan.confirmationTitle, + }); }; return ( diff --git a/src/pages/ResultNonUrl/lib/resolveNonUrlActionExecution.test.ts b/src/pages/ResultNonUrl/lib/resolveNonUrlActionExecution.test.ts new file mode 100644 index 0000000..9017953 --- /dev/null +++ b/src/pages/ResultNonUrl/lib/resolveNonUrlActionExecution.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from 'vitest'; + +import { resolveNonUrlActionExecution } from './resolveNonUrlActionExecution'; + +describe('resolveNonUrlActionExecution', () => { + it('normalizes web URLs and rejects credential-bearing URLs', () => { + expect(resolveNonUrlActionExecution('WEB', 'example.com')).toMatchObject({ + kind: 'open', + url: 'https://example.com', + }); + + expect(resolveNonUrlActionExecution('WEB', 'https://user:password@example.com')).toMatchObject({ + kind: 'unsupported', + }); + }); + + it('sanitizes phone and message schemes before navigation', () => { + expect(resolveNonUrlActionExecution('TEL', 'tel:010-1234-5678?ignored=1')).toMatchObject({ + confirmationTitle: expect.any(String), + href: 'tel:01012345678', + kind: 'navigate', + }); + + expect(resolveNonUrlActionExecution('SMS', 'sms:+82 10 1234 5678?body=hello')).toMatchObject({ + href: 'smsto:+821012345678', + kind: 'navigate', + }); + }); + + it('sanitizes mailto payloads before navigation', () => { + expect( + resolveNonUrlActionExecution('EMAIL', 'mailto:help@example.com?subject=secret'), + ).toMatchObject({ + href: 'mailto:help@example.com', + kind: 'navigate', + }); + }); + + it('allows only known deep link schemes', () => { + expect(resolveNonUrlActionExecution('DEEP_LINK', 'kakaotalk://chat')).toMatchObject({ + href: 'kakaotalk://chat', + kind: 'navigate', + }); + + expect(resolveNonUrlActionExecution('DEEP_LINK', 'unknownapp://open')).toMatchObject({ + kind: 'unsupported', + }); + }); + + it('blocks script-like schemes for app and crypto actions', () => { + const scriptLikeScheme = `java${'script'}:alert(1)`; + + expect(resolveNonUrlActionExecution('DEEP_LINK', scriptLikeScheme)).toMatchObject({ + kind: 'unsupported', + }); + + expect(resolveNonUrlActionExecution('CRYPTO', scriptLikeScheme)).toMatchObject({ + kind: 'unsupported', + }); + }); + + it('allows known crypto schemes and safely prefixes bare addresses', () => { + expect( + resolveNonUrlActionExecution('CRYPTO', 'bitcoin:1BoatSLRHtKNngkdXEeobR76b53LETtpyT'), + ).toMatchObject({ + href: 'bitcoin:1BoatSLRHtKNngkdXEeobR76b53LETtpyT', + kind: 'navigate', + }); + + expect( + resolveNonUrlActionExecution('CRYPTO', '1BoatSLRHtKNngkdXEeobR76b53LETtpyT'), + ).toMatchObject({ + href: 'bitcoin:1BoatSLRHtKNngkdXEeobR76b53LETtpyT', + kind: 'navigate', + }); + }); +}); diff --git a/src/pages/ResultNonUrl/lib/resolveNonUrlActionExecution.ts b/src/pages/ResultNonUrl/lib/resolveNonUrlActionExecution.ts index 8a83978..650ec81 100644 --- a/src/pages/ResultNonUrl/lib/resolveNonUrlActionExecution.ts +++ b/src/pages/ResultNonUrl/lib/resolveNonUrlActionExecution.ts @@ -1,16 +1,22 @@ +import { isSafeExternalUrl } from '@/shared/lib/security/isSafeExternalUrl'; + import type { NonUrlActionType } from '../types/resultNonUrlPage.types'; -type NonUrlActionExecutionPlan = - | { +type NonUrlActionExecutablePlanBase = { + confirmationContent: string; + confirmationTitle: string; + message: string; +}; + +export type NonUrlActionExecutionPlan = + | ({ href: string; kind: 'navigate'; - message: string; - } - | { + } & NonUrlActionExecutablePlanBase) + | ({ kind: 'open'; - message: string; url: string; - } + } & NonUrlActionExecutablePlanBase) | { kind: 'unsupported'; message: string; @@ -19,6 +25,44 @@ type NonUrlActionExecutionPlan = const httpUrlPattern = /^https?:\/\//iu; const genericSchemePattern = /^[a-z][a-z0-9+.-]*:/iu; const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/u; +const blockedSchemeNames = new Set([ + 'about', + 'blob', + 'chrome', + 'chrome-extension', + 'data', + 'file', + 'filesystem', + 'javascript', + 'vbscript', +]); +const allowedCryptoSchemeNames = new Set([ + 'bitcoin', + 'doge', + 'dogecoin', + 'eth', + 'ethereum', + 'litecoin', + 'ltc', + 'sol', + 'solana', + 'tron', + 'trx', + 'walletconnect', + 'xrp', +]); +const allowedDeepLinkSchemeNames = new Set([ + 'instagram', + 'kakaopay', + 'kakaotalk', + 'line', + 'naversearchapp', +]); +const appExecutionConfirmation = { + confirmationContent: + 'QR 코드가 외부 앱 또는 브라우저를 열려고 합니다. 대상 값을 다시 확인한 뒤 실행하세요.', + confirmationTitle: 'QR 동작 실행 확인', +}; function normalizePhoneNumber(targetValue?: string): string { if (!targetValue) { @@ -32,6 +76,42 @@ function isValidPhoneNumber(targetValue: string): boolean { return /^\+?\d{7,15}$/u.test(targetValue); } +function getSchemeName(targetValue: string): string | null { + const schemeMatch = targetValue.match(/^([a-z][a-z0-9+.-]*):/iu); + return schemeMatch ? schemeMatch[1].toLowerCase() : null; +} + +function stripSchemePrefix(targetValue: string, schemeName: string): string { + return targetValue.slice(schemeName.length + 1); +} + +function stripQueryAndFragment(targetValue: string): string { + return targetValue.split(/[?#]/u, 1)[0] ?? ''; +} + +function resolveAllowedSchemeHref( + targetValue: string | undefined, + allowedSchemeNames: Set, +): string | null { + const normalizedTargetValue = targetValue?.trim(); + + if (!normalizedTargetValue || !genericSchemePattern.test(normalizedTargetValue)) { + return null; + } + + const schemeName = getSchemeName(normalizedTargetValue); + + if (!schemeName || blockedSchemeNames.has(schemeName) || !allowedSchemeNames.has(schemeName)) { + return null; + } + + return normalizedTargetValue; +} + +function isSafeCryptoAddress(targetValue: string): boolean { + return /^[A-Za-z0-9][A-Za-z0-9._~:/?#@!$&'()*+,;=%-]{2,511}$/u.test(targetValue); +} + function normalizeHttpUrl(targetValue?: string): string | null { const normalizedTargetValue = targetValue?.trim(); @@ -40,11 +120,12 @@ function normalizeHttpUrl(targetValue?: string): string | null { } if (httpUrlPattern.test(normalizedTargetValue)) { - return normalizedTargetValue; + return isSafeExternalUrl(normalizedTargetValue) ? normalizedTargetValue : null; } if (/^[\w.-]+\.[a-z]{2,}(?:[/?#].*)?$/iu.test(normalizedTargetValue)) { - return `https://${normalizedTargetValue}`; + const url = `https://${normalizedTargetValue}`; + return isSafeExternalUrl(url) ? url : null; } return null; @@ -57,11 +138,16 @@ function resolveTelHref(targetValue?: string): string | null { return null; } - if (/^tel:/iu.test(normalizedTargetValue)) { - return normalizedTargetValue; + const schemeName = getSchemeName(normalizedTargetValue); + + if (schemeName && schemeName !== 'tel') { + return null; } - const normalizedPhoneNumber = normalizePhoneNumber(normalizedTargetValue); + const phoneValue = schemeName + ? stripSchemePrefix(normalizedTargetValue, schemeName) + : normalizedTargetValue; + const normalizedPhoneNumber = normalizePhoneNumber(stripQueryAndFragment(phoneValue)); return isValidPhoneNumber(normalizedPhoneNumber) ? `tel:${normalizedPhoneNumber}` : null; } @@ -73,11 +159,16 @@ function resolveSmsHref(targetValue?: string): string | null { return null; } - if (/^(smsto|sms):/iu.test(normalizedTargetValue)) { - return normalizedTargetValue; + const schemeName = getSchemeName(normalizedTargetValue); + + if (schemeName && schemeName !== 'sms' && schemeName !== 'smsto') { + return null; } - const normalizedPhoneNumber = normalizePhoneNumber(normalizedTargetValue); + const phoneValue = schemeName + ? stripSchemePrefix(normalizedTargetValue, schemeName) + : normalizedTargetValue; + const normalizedPhoneNumber = normalizePhoneNumber(stripQueryAndFragment(phoneValue)); return isValidPhoneNumber(normalizedPhoneNumber) ? `smsto:${normalizedPhoneNumber}` : null; } @@ -89,21 +180,34 @@ function resolveMailtoHref(targetValue?: string): string | null { return null; } - if (/^mailto:/iu.test(normalizedTargetValue)) { - return normalizedTargetValue; + const schemeName = getSchemeName(normalizedTargetValue); + + if (schemeName && schemeName !== 'mailto') { + return null; } - return emailPattern.test(normalizedTargetValue) ? `mailto:${normalizedTargetValue}` : null; + const emailValue = schemeName + ? stripSchemePrefix(normalizedTargetValue, schemeName) + : normalizedTargetValue; + const normalizedEmail = stripQueryAndFragment(emailValue); + + return emailPattern.test(normalizedEmail) ? `mailto:${normalizedEmail}` : null; } -function resolveSchemeHref(targetValue?: string): string | null { +function resolveCryptoHref(targetValue?: string): string | null { const normalizedTargetValue = targetValue?.trim(); - if (!normalizedTargetValue || !genericSchemePattern.test(normalizedTargetValue)) { + if (!normalizedTargetValue) { return null; } - return normalizedTargetValue; + const schemeName = getSchemeName(normalizedTargetValue); + + if (schemeName) { + return resolveAllowedSchemeHref(normalizedTargetValue, allowedCryptoSchemeNames); + } + + return isSafeCryptoAddress(normalizedTargetValue) ? `bitcoin:${normalizedTargetValue}` : null; } export function resolveNonUrlActionExecution( @@ -124,6 +228,7 @@ export function resolveNonUrlActionExecution( return { kind: 'open', message: '웹페이지를 열고 있습니다.', + ...appExecutionConfirmation, url, }; } @@ -141,6 +246,7 @@ export function resolveNonUrlActionExecution( return { kind: 'open', message: '단축 URL을 열고 있습니다. 최종 이동 주소를 다시 확인하세요.', + ...appExecutionConfirmation, url, }; } @@ -158,6 +264,7 @@ export function resolveNonUrlActionExecution( return { href, kind: 'navigate', + ...appExecutionConfirmation, message: '전화 앱 실행을 시도합니다.', }; } @@ -175,6 +282,7 @@ export function resolveNonUrlActionExecution( return { href, kind: 'navigate', + ...appExecutionConfirmation, message: '문자 앱 실행을 시도합니다.', }; } @@ -192,6 +300,7 @@ export function resolveNonUrlActionExecution( return { href, kind: 'navigate', + ...appExecutionConfirmation, message: '메일 앱 실행을 시도합니다.', }; } @@ -203,6 +312,7 @@ export function resolveNonUrlActionExecution( return { kind: 'open', message: '앱 마켓 페이지를 열고 있습니다.', + ...appExecutionConfirmation, url: url ?? `https://play.google.com/store/search?c=apps&q=${encodeURIComponent(keyword || 'app')}`, @@ -210,7 +320,7 @@ export function resolveNonUrlActionExecution( } case 'DEEP_LINK': { - const href = resolveSchemeHref(targetValue); + const href = resolveAllowedSchemeHref(targetValue, allowedDeepLinkSchemeNames); if (!href) { return { @@ -222,14 +332,13 @@ export function resolveNonUrlActionExecution( return { href, kind: 'navigate', + ...appExecutionConfirmation, message: '앱 딥링크 실행을 시도합니다.', }; } case 'CRYPTO': { - const href = - resolveSchemeHref(targetValue) ?? - ((targetValue ?? '').trim() ? `bitcoin:${targetValue?.trim()}` : null); + const href = resolveCryptoHref(targetValue); if (!href) { return { @@ -241,6 +350,7 @@ export function resolveNonUrlActionExecution( return { href, kind: 'navigate', + ...appExecutionConfirmation, message: '지갑 또는 결제 앱 실행을 시도합니다.', }; } From a608d03a6f023c5cc5ce1ec537a2cf9905ee7256 Mon Sep 17 00:00:00 2001 From: kimsubsub Date: Thu, 14 May 2026 20:46:37 +0900 Subject: [PATCH 12/27] =?UTF-8?q?docs:=20=EB=A6=AC=ED=8C=A9=ED=86=A0?= =?UTF-8?q?=EB=A7=81=20=EC=A7=84=ED=96=89=20=EA=B8=B0=EB=A1=9D=20=EA=B0=B1?= =?UTF-8?q?=EC=8B=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/refactor-roadmap.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/refactor-roadmap.md b/docs/refactor-roadmap.md index 62ee10f..b5d5973 100644 --- a/docs/refactor-roadmap.md +++ b/docs/refactor-roadmap.md @@ -9,6 +9,11 @@ - 타입 체크: `pnpm typecheck` 통과 - 빌드: `pnpm build` 통과 +## 진행 기록 + +- 2026-05-14: P0 CI 안정성 보강 완료. `typecheck` 스크립트와 CI 단계를 추가했다. +- 2026-05-14: P1 비 URL 실행 보안 1차 보강 완료. 실행 가능한 스킴을 제한하고, 실행 전 확인 단계를 추가했다. + ## 관찰 결과 - `vite build`만으로는 TypeScript 타입 오류가 잡히지 않는다. 실제로 `ResultNonUrlPage`의 존재하지 않는 필드 접근이 `tsc --noEmit`에서만 발견됐다. From 2088b147113e696dbc95ae35dd3addf2a40fb2f3 Mon Sep 17 00:00:00 2001 From: kimsubsub Date: Thu, 14 May 2026 20:51:35 +0900 Subject: [PATCH 13/27] =?UTF-8?q?refactor:=20=EC=8A=A4=EC=BA=94=20?= =?UTF-8?q?=EC=9D=91=EB=8B=B5=20=EC=A0=95=EA=B7=9C=ED=99=94=20=EC=9C=A0?= =?UTF-8?q?=ED=8B=B8=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/QRScan/hooks/useQRScanPage.ts | 27 +---- .../scanResultNormalization.test.ts | 57 +++++++++ .../scan-session/scanResultNormalization.ts | 83 +++++++++++++ src/shared/store/scanSessionStore.ts | 114 ++++-------------- 4 files changed, 170 insertions(+), 111 deletions(-) create mode 100644 src/shared/lib/scan-session/scanResultNormalization.test.ts create mode 100644 src/shared/lib/scan-session/scanResultNormalization.ts diff --git a/src/pages/QRScan/hooks/useQRScanPage.ts b/src/pages/QRScan/hooks/useQRScanPage.ts index 819a591..4bd91b3 100644 --- a/src/pages/QRScan/hooks/useQRScanPage.ts +++ b/src/pages/QRScan/hooks/useQRScanPage.ts @@ -6,6 +6,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { showApiError } from '@/shared/lib/feedback/showApiError'; import { isWebScanTarget } from '@/shared/lib/scan-session/scanClassification'; +import { normalizeScanResult } from '@/shared/lib/scan-session/scanResultNormalization'; import { useScanProgressStore } from '@/shared/store/scanProgressStore'; import { useScanSessionStore } from '@/shared/store/scanSessionStore'; @@ -138,29 +139,9 @@ async function requestCameraStream() { } function isNonWebScanResponse(scanResponse: Record): boolean { - const rawSchemeType = - typeof scanResponse.schemeType === 'string' - ? scanResponse.schemeType - : scanResponse.scheme_type; - const targetValue = - typeof scanResponse.typeInfo === 'string' - ? scanResponse.typeInfo - : typeof scanResponse.type_info === 'string' - ? scanResponse.type_info - : typeof scanResponse.decodedUrl === 'string' - ? scanResponse.decodedUrl - : typeof scanResponse.decoded_url === 'string' - ? scanResponse.decoded_url - : typeof scanResponse.url === 'string' - ? scanResponse.url - : null; - const rawIsUrl = scanResponse.isUrl !== undefined ? scanResponse.isUrl : scanResponse.is_url; - - return !isWebScanTarget({ - isUrl: typeof rawIsUrl === 'boolean' ? rawIsUrl : null, - schemeType: typeof rawSchemeType === 'string' ? rawSchemeType : null, - url: targetValue, - }); + const normalizedResult = normalizeScanResult(scanResponse); + + return !isWebScanTarget(normalizedResult); } function isWebHistoryItem(item: ScanHistoryItem): boolean { diff --git a/src/shared/lib/scan-session/scanResultNormalization.test.ts b/src/shared/lib/scan-session/scanResultNormalization.test.ts new file mode 100644 index 0000000..85999ad --- /dev/null +++ b/src/shared/lib/scan-session/scanResultNormalization.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from 'vitest'; + +import { + normalizeScanResult, + resolveScanDecodedUrl, + resolveScanIsUrl, + resolveScanRiskLevel, + resolveScanSchemeType, +} from './scanResultNormalization'; + +describe('scanResultNormalization', () => { + it('prefers final URL fields when decoding backend analysis detail', () => { + expect( + resolveScanDecodedUrl({ + originalUrl: 'https://short.example/a', + redirect: { + finalUrl: 'https://final.example/path', + }, + }), + ).toBe('https://final.example/path'); + }); + + it('normalizes URL scheme aliases as web scans', () => { + const decodedUrl = 'https://example.com/history'; + const schemeType = resolveScanSchemeType( + { + schemeType: 'URL', + }, + decodedUrl, + ); + + expect(schemeType).toBe('WEB'); + expect(resolveScanIsUrl({ isUrl: false }, decodedUrl, schemeType)).toBe(true); + }); + + it('infers non-web scans from non-http target values', () => { + expect( + normalizeScanResult({ + scheme_type: 'SMS', + targetValue: '01012345678', + }), + ).toMatchObject({ + decodedUrl: '01012345678', + isUrl: false, + schemeType: 'SMS', + }); + }); + + it('uses score before riskLevel when resolving risk', () => { + expect( + resolveScanRiskLevel({ + riskLevel: 'safe', + score: 83, + }), + ).toBe('critical'); + }); +}); diff --git a/src/shared/lib/scan-session/scanResultNormalization.ts b/src/shared/lib/scan-session/scanResultNormalization.ts new file mode 100644 index 0000000..20447f1 --- /dev/null +++ b/src/shared/lib/scan-session/scanResultNormalization.ts @@ -0,0 +1,83 @@ +import { pickBoolean, pickString } from '@/shared/api/responseAccess/payloadAccess'; +import { resolveResultToneFromSource } from '@/shared/api/risk/resolveResultTone'; +import type { ResultTone } from '@/shared/types/resultTone'; + +import { isHttpUrl, normalizeScanSchemeTypeAlias } from './scanClassification'; + +const decodedUrlKeys = [ + 'decodedUrl', + 'decoded_url', + 'destinationUrl', + 'destination_url', + 'finalUrl', + 'final_url', + 'scannedUrl', + 'scanned_url', + 'typeInfo', + 'type_info', + 'targetValue', + 'target_value', + 'url', +]; + +const schemeTypeKeys = ['schemeType', 'scheme_type']; +const isUrlKeys = ['isUrl', 'is_url']; + +export type NormalizedScanResult = { + decodedUrl: string | null; + isUrl: boolean | null; + riskLevel: ResultTone | null; + schemeType: string | null; +}; + +export function resolveScanDecodedUrl(source: unknown): string | null { + return pickString(source, decodedUrlKeys); +} + +export function resolveScanSchemeType(source: unknown, decodedUrl: string | null): string | null { + const explicitSchemeType = normalizeScanSchemeTypeAlias(pickString(source, schemeTypeKeys)); + + if (explicitSchemeType) { + return explicitSchemeType; + } + + if (!decodedUrl) { + return null; + } + + return isHttpUrl(decodedUrl) ? 'WEB' : 'NON_WEB'; +} + +export function resolveScanIsUrl( + source: unknown, + decodedUrl: string | null, + schemeType: string | null, +): boolean | null { + const explicitIsUrl = pickBoolean(source, isUrlKeys); + + if (schemeType) { + return schemeType === 'WEB'; + } + + if (isHttpUrl(decodedUrl)) { + return true; + } + + return explicitIsUrl; +} + +export function resolveScanRiskLevel(source: unknown): ResultTone | null { + return resolveResultToneFromSource(source); +} + +export function normalizeScanResult(source: unknown): NormalizedScanResult { + const decodedUrl = resolveScanDecodedUrl(source); + const schemeType = resolveScanSchemeType(source, decodedUrl); + + return { + decodedUrl, + isUrl: resolveScanIsUrl(source, decodedUrl, schemeType), + riskLevel: resolveScanRiskLevel(source), + schemeType, + }; +} diff --git a/src/shared/store/scanSessionStore.ts b/src/shared/store/scanSessionStore.ts index 11adb0d..a725b34 100644 --- a/src/shared/store/scanSessionStore.ts +++ b/src/shared/store/scanSessionStore.ts @@ -1,17 +1,19 @@ import { create } from 'zustand'; import { createJSONStorage, persist, type StateStorage } from 'zustand/middleware'; -import { pickBoolean, pickString } from '@/shared/api/responseAccess/payloadAccess'; -import { resolveResultToneFromSource } from '@/shared/api/risk/resolveResultTone'; import type { BackendAnalysisDetailResponse, BackendScanResponse, BackendSseFinalPayload, } from '@/shared/api/types'; +import { normalizeScanSchemeTypeAlias } from '@/shared/lib/scan-session/scanClassification'; import { - isHttpUrl, - normalizeScanSchemeTypeAlias, -} from '@/shared/lib/scan-session/scanClassification'; + normalizeScanResult, + resolveScanDecodedUrl, + resolveScanIsUrl, + resolveScanRiskLevel, + resolveScanSchemeType, +} from '@/shared/lib/scan-session/scanResultNormalization'; import type { ResultTone } from '@/shared/types/resultTone'; export type ScanHistorySelection = { @@ -96,70 +98,6 @@ function mergePersistedLightSession( }; } -function resolveDecodedUrl(source: unknown): string | null { - return pickString(source, [ - 'decodedUrl', - 'decoded_url', - 'destinationUrl', - 'destination_url', - 'finalUrl', - 'final_url', - 'scannedUrl', - 'scanned_url', - 'typeInfo', - 'type_info', - 'targetValue', - 'target_value', - 'url', - ]); -} - -function resolveSchemeType(source: unknown, decodedUrl: string | null): string | null { - const explicitSchemeType = normalizeScanSchemeTypeAlias( - pickString(source, ['schemeType', 'scheme_type']), - ); - - if (explicitSchemeType) { - return explicitSchemeType; - } - - if (!decodedUrl) { - return null; - } - - if (isHttpUrl(decodedUrl)) { - return 'WEB'; - } - - return 'NON_WEB'; -} - -function resolveIsUrl( - source: unknown, - decodedUrl: string | null, - schemeType: string | null, -): boolean | null { - const explicitIsUrl = pickBoolean(source, ['isUrl', 'is_url']); - - if (schemeType) { - return schemeType === 'WEB'; - } - - if (isHttpUrl(decodedUrl)) { - return true; - } - - if (explicitIsUrl !== null) { - return explicitIsUrl; - } - - return null; -} - -function resolveRiskLevel(source: unknown): ResultTone | null { - return resolveResultToneFromSource(source); -} - export const useScanSessionStore = create()( persist( (set) => ({ @@ -180,65 +118,65 @@ export const useScanSessionStore = create()( }); }, setAnalysisDetail: (analysisDetail) => { - const decodedUrl = resolveDecodedUrl(analysisDetail); + const normalizedResult = normalizeScanResult(analysisDetail); set((state) => { - const nextDecodedUrl = decodedUrl ?? state.decodedUrl; - const schemeType = resolveSchemeType(analysisDetail, nextDecodedUrl); + const nextDecodedUrl = normalizedResult.decodedUrl ?? state.decodedUrl; + const schemeType = + normalizedResult.schemeType ?? resolveScanSchemeType(analysisDetail, nextDecodedUrl); const nextSchemeType = schemeType ?? state.schemeType; return { analysisDetail, decodedUrl: nextDecodedUrl, - isUrl: resolveIsUrl(analysisDetail, nextDecodedUrl, nextSchemeType) ?? state.isUrl, - riskLevel: resolveRiskLevel(analysisDetail) ?? state.riskLevel, + isUrl: resolveScanIsUrl(analysisDetail, nextDecodedUrl, nextSchemeType) ?? state.isUrl, + riskLevel: normalizedResult.riskLevel ?? state.riskLevel, schemeType: nextSchemeType, }; }); }, setFinalResult: (finalResult) => { - const decodedUrl = resolveDecodedUrl(finalResult); - const schemeType = resolveSchemeType(finalResult, decodedUrl); + const normalizedResult = normalizeScanResult(finalResult); set((state) => ({ - decodedUrl: decodedUrl ?? state.decodedUrl, + decodedUrl: normalizedResult.decodedUrl ?? state.decodedUrl, finalResult, historySelection: state.historySelection, isUrl: - resolveIsUrl( + resolveScanIsUrl( finalResult, - decodedUrl ?? state.decodedUrl, - schemeType ?? state.schemeType, + normalizedResult.decodedUrl ?? state.decodedUrl, + normalizedResult.schemeType ?? state.schemeType, ) ?? state.isUrl, - riskLevel: resolveRiskLevel(finalResult) ?? state.riskLevel, - schemeType: schemeType ?? state.schemeType, + riskLevel: normalizedResult.riskLevel ?? state.riskLevel, + schemeType: normalizedResult.schemeType ?? state.schemeType, })); }, setHistorySelection: (historySelection) => { - const schemeType = resolveSchemeType(historySelection, historySelection.url); + const schemeType = resolveScanSchemeType(historySelection, historySelection.url); set({ analysisDetail: null, decodedUrl: historySelection.url, finalResult: null, historySelection, - isUrl: resolveIsUrl(historySelection, historySelection.url, schemeType), + isUrl: resolveScanIsUrl(historySelection, historySelection.url, schemeType), riskLevel: historySelection.riskLevel, scanResponse: null, schemeType, }); }, setScanResponse: (scanResponse) => { - const decodedUrl = resolveDecodedUrl(scanResponse); - const schemeType = resolveSchemeType(scanResponse, decodedUrl); + const decodedUrl = resolveScanDecodedUrl(scanResponse); + const schemeType = resolveScanSchemeType(scanResponse, decodedUrl); set({ analysisDetail: null, decodedUrl, finalResult: null, historySelection: null, - isUrl: resolveIsUrl(scanResponse, decodedUrl, schemeType), - riskLevel: resolveRiskLevel(scanResponse), + isUrl: resolveScanIsUrl(scanResponse, decodedUrl, schemeType), + riskLevel: resolveScanRiskLevel(scanResponse), scanResponse, schemeType, }); From ab6c140e77e25df18bcf1fa9ce85adca1c15e8ce Mon Sep 17 00:00:00 2001 From: kimsubsub Date: Thu, 14 May 2026 20:51:45 +0900 Subject: [PATCH 14/27] =?UTF-8?q?docs:=20=EC=9D=91=EB=8B=B5=20=EC=A0=95?= =?UTF-8?q?=EA=B7=9C=ED=99=94=20=EC=A7=84=ED=96=89=20=EA=B8=B0=EB=A1=9D=20?= =?UTF-8?q?=EA=B0=B1=EC=8B=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/refactor-roadmap.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/refactor-roadmap.md b/docs/refactor-roadmap.md index b5d5973..5897454 100644 --- a/docs/refactor-roadmap.md +++ b/docs/refactor-roadmap.md @@ -13,6 +13,7 @@ - 2026-05-14: P0 CI 안정성 보강 완료. `typecheck` 스크립트와 CI 단계를 추가했다. - 2026-05-14: P1 비 URL 실행 보안 1차 보강 완료. 실행 가능한 스킴을 제한하고, 실행 전 확인 단계를 추가했다. +- 2026-05-14: P1 응답 정규화 1차 보강 완료. URL, 스키마, 웹 여부, 위험도 판정을 공통 유틸로 분리했다. ## 관찰 결과 From 997ee96d6ff10e7dbc2446bc14200dd88c9e8a89 Mon Sep 17 00:00:00 2001 From: kimsubsub Date: Thu, 14 May 2026 21:58:45 +0900 Subject: [PATCH 15/27] =?UTF-8?q?refactor:=20=EC=8A=A4=EC=BA=94=20URL=20?= =?UTF-8?q?=ED=95=B4=EC=84=9D=20=EA=B3=B5=ED=86=B5=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/Report/lib/toReportPageData.ts | 67 +++------------- src/pages/Result/lib/toResultPageData.test.ts | 25 ++++++ src/pages/Result/lib/toResultPageData.ts | 46 +++-------- .../scan-session/scanUrlResolution.test.ts | 55 +++++++++++++ .../lib/scan-session/scanUrlResolution.ts | 80 +++++++++++++++++++ 5 files changed, 183 insertions(+), 90 deletions(-) create mode 100644 src/shared/lib/scan-session/scanUrlResolution.test.ts create mode 100644 src/shared/lib/scan-session/scanUrlResolution.ts diff --git a/src/pages/Report/lib/toReportPageData.ts b/src/pages/Report/lib/toReportPageData.ts index 2ef7ce9..b547fcc 100644 --- a/src/pages/Report/lib/toReportPageData.ts +++ b/src/pages/Report/lib/toReportPageData.ts @@ -8,6 +8,10 @@ import { pickSourceStringArray, } from '@/shared/api/responseAccess/payloadAccess'; import { resolveResultToneFromSources } from '@/shared/api/risk/resolveResultTone'; +import { + resolveScanUrls, + type ResolvedScanUrls, +} from '@/shared/lib/scan-session/scanUrlResolution'; import type { ScanSessionSnapshot } from '@/shared/store/scanSessionStore'; import type { ResultTone } from '@/shared/types/resultTone'; @@ -19,27 +23,6 @@ import { import type { ReportPageData } from '../types/reportPage.types'; -type ReportUrls = { - destinationUrl: string; - originalUrl: string; - scannedAt: string | null; - scannedUrl: string; -}; - -const scannedUrlKeys = [ - 'decodedUrl', - 'decoded_url', - 'originalUrl', - 'original_url', - 'scannedUrl', - 'scanned_url', - 'url', -]; - -const scannedUrlFallbackKeys = ['finalUrl', 'final_url', 'destinationUrl', 'destination_url']; - -const destinationUrlKeys = ['finalUrl', 'final_url', 'destinationUrl', 'destination_url']; - function clampCount(value: number | null): number { return value === null ? 0 : Math.max(0, Math.round(value)); } @@ -127,37 +110,6 @@ function resolveCertificateTone( return 'warning'; } -function resolveReportUrls(session: ScanSessionSnapshot, sources: unknown[]): ReportUrls { - const scannedUrl = - pickSourceString(sources, scannedUrlKeys) ?? - session.decodedUrl ?? - session.historySelection?.url ?? - pickSourceString(sources, scannedUrlFallbackKeys) ?? - ''; - const originalUrl = - pickSourceString(sources, ['originalUrl', 'original_url', 'sourceUrl', 'source_url']) ?? - scannedUrl; - const destinationUrl = pickSourceString(sources, destinationUrlKeys) ?? scannedUrl; - const scannedAt = - pickSourceString(sources, [ - 'analysisTime', - 'analysis_time', - 'scannedAt', - 'scanned_at', - 'createdAt', - 'created_at', - ]) ?? - session.historySelection?.scannedAt ?? - null; - - return { - destinationUrl, - originalUrl, - scannedAt, - scannedUrl, - }; -} - function resolveDetectedRiskTypes(sources: unknown[], riskLevel: ResultTone): string[] { const riskTypes = pickSourceStringArray( sources, @@ -168,7 +120,7 @@ function resolveDetectedRiskTypes(sources: unknown[], riskLevel: ResultTone): st return riskTypes.length > 0 ? riskTypes : reportFallbackCopyByTone[riskLevel].detectedRiskTypes; } -function resolveUrlComparisonSummary({ destinationUrl, originalUrl }: ReportUrls): string { +function resolveUrlComparisonSummary({ destinationUrl, originalUrl }: ResolvedScanUrls): string { if (originalUrl && destinationUrl && originalUrl !== destinationUrl) { return '스캔된 QR URL이 최종 목적지와 다른 주소로 리다이렉트됩니다.'; } @@ -256,7 +208,7 @@ function resolveServerInfoRecord(rawServerInfoRecord: Record | function buildDomainComparison( domainComparisonRecord: Record | null, riskLevel: ResultTone, - urls: ReportUrls, + urls: ResolvedScanUrls, ): ReportPageData['domainComparison'] { return { officialUrl: @@ -338,7 +290,12 @@ function buildServerInfo( export function toReportPageData(session: ScanSessionSnapshot): ReportPageData { const sources = getSessionSources(session); const riskLevel = resolveResultToneFromSources(sources, session.riskLevel) ?? 'warning'; - const urls = resolveReportUrls(session, sources); + const urls = resolveScanUrls({ + decodedUrl: session.decodedUrl, + historyScannedAt: session.historySelection?.scannedAt, + historyUrl: session.historySelection?.url, + sources, + }); const reputationRecord = pickSourceRecord(sources, [ 'reputation', 'reputationSummary', diff --git a/src/pages/Result/lib/toResultPageData.test.ts b/src/pages/Result/lib/toResultPageData.test.ts index 4d077a2..e36941b 100644 --- a/src/pages/Result/lib/toResultPageData.test.ts +++ b/src/pages/Result/lib/toResultPageData.test.ts @@ -54,4 +54,29 @@ describe('toResultPageData', () => { expect(resultPageData.riskLevel).toBe('critical'); expect(resultPageData.trustScore).toBe(85); }); + + it('keeps scanned and destination URLs distinct for redirects', () => { + const session: ScanSessionSnapshot = { + analysisDetail: { + originalUrl: 'https://short.example/a', + redirect: { + finalUrl: 'https://final.example/path', + }, + score: 45, + }, + decodedUrl: null, + finalResult: null, + historySelection: null, + isUrl: true, + riskLevel: null, + scanResponse: null, + schemeType: 'WEB', + }; + + const resultPageData = toResultPageData(session, 'warning'); + + expect(resultPageData.siteName).toBe('https://short.example/a'); + expect(resultPageData.siteUrl).toBe('https://final.example/path'); + expect(resultPageData.visitUrl).toBe('https://final.example/path'); + }); }); diff --git a/src/pages/Result/lib/toResultPageData.ts b/src/pages/Result/lib/toResultPageData.ts index 32ea112..7cfa679 100644 --- a/src/pages/Result/lib/toResultPageData.ts +++ b/src/pages/Result/lib/toResultPageData.ts @@ -5,6 +5,7 @@ import { pickSourceString, } from '@/shared/api/responseAccess/payloadAccess'; import { resolveResultToneFromSources } from '@/shared/api/risk/resolveResultTone'; +import { resolveScanUrls } from '@/shared/lib/scan-session/scanUrlResolution'; import type { ScanSessionSnapshot } from '@/shared/store/scanSessionStore'; import type { ResultTone } from '@/shared/types/resultTone'; @@ -117,37 +118,12 @@ export function toResultPageData( session.historySelection, ]; - const resolvedOriginalUrl = - pickSourceString(sources, [ - 'originalUrl', - 'original_url', - 'sourceUrl', - 'source_url', - 'scannedUrl', - 'scanned_url', - 'decodedUrl', - 'decoded_url', - 'url', - ]) ?? - session.decodedUrl ?? - session.historySelection?.url ?? - ''; - - const redirectRecord = pickSourceRecord(sources, ['redirect']); - const resolvedFinalUrl = - pickSourceString([redirectRecord], ['finalUrl', 'final_url']) ?? - pickSourceString(sources, [ - 'finalUrl', - 'final_url', - 'destinationUrl', - 'destination_url', - 'visitUrl', - 'visit_url', - ]) ?? - resolvedOriginalUrl; - - const resolvedPreviewUrl = - pickSourceString(sources, ['previewUrl', 'preview_url']) ?? resolvedFinalUrl; + const urls = resolveScanUrls({ + decodedUrl: session.decodedUrl, + historyScannedAt: session.historySelection?.scannedAt, + historyUrl: session.historySelection?.url, + sources, + }); const resolvedTone = resolveResultToneFromSources(sources, session.riskLevel) ?? fallbackTone; const resolvedTrustScore = pickSourceNumber(sources, ['trustScore', 'trust_score', 'score']) ?? @@ -155,12 +131,12 @@ export function toResultPageData( return { detailUnavailable: false, - previewUrl: resolvedPreviewUrl, + previewUrl: urls.previewUrl, riskLevel: resolvedTone, siteMeta: buildSiteMeta(sources), - siteName: resolvedOriginalUrl, - siteUrl: resolvedFinalUrl, + siteName: urls.scannedUrl, + siteUrl: urls.destinationUrl, trustScore: clampTrustScore(resolvedTrustScore), - visitUrl: resolvedFinalUrl, + visitUrl: urls.destinationUrl, }; } diff --git a/src/shared/lib/scan-session/scanUrlResolution.test.ts b/src/shared/lib/scan-session/scanUrlResolution.test.ts new file mode 100644 index 0000000..0b621cf --- /dev/null +++ b/src/shared/lib/scan-session/scanUrlResolution.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from 'vitest'; + +import { resolveScanUrls } from './scanUrlResolution'; + +describe('resolveScanUrls', () => { + it('keeps scanned and destination URLs distinct for redirects', () => { + expect( + resolveScanUrls({ + sources: [ + { + originalUrl: 'https://short.example/a', + redirect: { + finalUrl: 'https://final.example/path', + }, + }, + ], + }), + ).toMatchObject({ + destinationUrl: 'https://final.example/path', + originalUrl: 'https://short.example/a', + previewUrl: 'https://final.example/path', + scannedUrl: 'https://short.example/a', + }); + }); + + it('uses session decoded URL when scanned URL fields are absent', () => { + expect( + resolveScanUrls({ + decodedUrl: 'https://short.example/a', + sources: [ + { + finalUrl: 'https://final.example/path', + }, + ], + }), + ).toMatchObject({ + destinationUrl: 'https://final.example/path', + scannedUrl: 'https://short.example/a', + }); + }); + + it('falls back to history URL and scanned timestamp', () => { + expect( + resolveScanUrls({ + historyScannedAt: '2026.05.14', + historyUrl: 'https://history.example/path', + sources: [], + }), + ).toMatchObject({ + destinationUrl: 'https://history.example/path', + scannedAt: '2026.05.14', + scannedUrl: 'https://history.example/path', + }); + }); +}); diff --git a/src/shared/lib/scan-session/scanUrlResolution.ts b/src/shared/lib/scan-session/scanUrlResolution.ts new file mode 100644 index 0000000..9aae2dc --- /dev/null +++ b/src/shared/lib/scan-session/scanUrlResolution.ts @@ -0,0 +1,80 @@ +import { pickSourceString } from '@/shared/api/responseAccess/payloadAccess'; + +const scannedUrlKeys = [ + 'originalUrl', + 'original_url', + 'sourceUrl', + 'source_url', + 'scannedUrl', + 'scanned_url', + 'decodedUrl', + 'decoded_url', + 'url', + 'typeInfo', + 'type_info', + 'targetValue', + 'target_value', +]; + +const originalUrlKeys = ['originalUrl', 'original_url', 'sourceUrl', 'source_url']; + +const destinationUrlKeys = [ + 'finalUrl', + 'final_url', + 'destinationUrl', + 'destination_url', + 'visitUrl', + 'visit_url', +]; + +const previewUrlKeys = ['previewUrl', 'preview_url']; + +const scannedAtKeys = [ + 'analysisTime', + 'analysis_time', + 'scannedAt', + 'scanned_at', + 'createdAt', + 'created_at', +]; + +export type ResolveScanUrlsOptions = { + decodedUrl?: string | null; + historyScannedAt?: string | null; + historyUrl?: string | null; + sources: unknown[]; +}; + +export type ResolvedScanUrls = { + destinationUrl: string; + originalUrl: string; + previewUrl: string; + scannedAt: string | null; + scannedUrl: string; +}; + +export function resolveScanUrls({ + decodedUrl, + historyScannedAt, + historyUrl, + sources, +}: ResolveScanUrlsOptions): ResolvedScanUrls { + const scannedUrl = + pickSourceString(sources, scannedUrlKeys) ?? + decodedUrl ?? + historyUrl ?? + pickSourceString(sources, destinationUrlKeys) ?? + ''; + const originalUrl = pickSourceString(sources, originalUrlKeys) ?? scannedUrl; + const destinationUrl = pickSourceString(sources, destinationUrlKeys) ?? scannedUrl; + const previewUrl = pickSourceString(sources, previewUrlKeys) ?? destinationUrl; + const scannedAt = pickSourceString(sources, scannedAtKeys) ?? historyScannedAt ?? null; + + return { + destinationUrl, + originalUrl, + previewUrl, + scannedAt, + scannedUrl, + }; +} From 7b8161e24faaca807f87b7a009f5233eebdc658c Mon Sep 17 00:00:00 2001 From: kimsubsub Date: Thu, 14 May 2026 21:59:00 +0900 Subject: [PATCH 16/27] =?UTF-8?q?docs:=20URL=20=ED=95=B4=EC=84=9D=20?= =?UTF-8?q?=EA=B3=B5=ED=86=B5=ED=99=94=20=EA=B8=B0=EB=A1=9D=20=EA=B0=B1?= =?UTF-8?q?=EC=8B=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/refactor-roadmap.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/refactor-roadmap.md b/docs/refactor-roadmap.md index 5897454..afff1bf 100644 --- a/docs/refactor-roadmap.md +++ b/docs/refactor-roadmap.md @@ -14,6 +14,7 @@ - 2026-05-14: P0 CI 안정성 보강 완료. `typecheck` 스크립트와 CI 단계를 추가했다. - 2026-05-14: P1 비 URL 실행 보안 1차 보강 완료. 실행 가능한 스킴을 제한하고, 실행 전 확인 단계를 추가했다. - 2026-05-14: P1 응답 정규화 1차 보강 완료. URL, 스키마, 웹 여부, 위험도 판정을 공통 유틸로 분리했다. +- 2026-05-14: P1 URL 해석 1차 보강 완료. 결과/리포트 페이지의 스캔 URL, 원본 URL, 최종 URL 선택 기준을 공통화했다. ## 관찰 결과 From 586c24f1cd0f8cd1e0f62e8aa5a9473f86dbf045 Mon Sep 17 00:00:00 2001 From: kimsubsub Date: Thu, 14 May 2026 22:04:44 +0900 Subject: [PATCH 17/27] =?UTF-8?q?refactor:=20=EC=8A=A4=EC=BA=94=20?= =?UTF-8?q?=EC=84=B8=EC=85=98=20=EC=83=81=ED=83=9C=20=EC=A0=84=ED=99=98=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/shared/store/scanSessionStore.ts | 74 ++----------- .../store/scanSessionTransitions.test.ts | 101 ++++++++++++++++++ src/shared/store/scanSessionTransitions.ts | 83 ++++++++++++++ 3 files changed, 195 insertions(+), 63 deletions(-) create mode 100644 src/shared/store/scanSessionTransitions.test.ts create mode 100644 src/shared/store/scanSessionTransitions.ts diff --git a/src/shared/store/scanSessionStore.ts b/src/shared/store/scanSessionStore.ts index a725b34..b3d4b31 100644 --- a/src/shared/store/scanSessionStore.ts +++ b/src/shared/store/scanSessionStore.ts @@ -7,15 +7,15 @@ import type { BackendSseFinalPayload, } from '@/shared/api/types'; import { normalizeScanSchemeTypeAlias } from '@/shared/lib/scan-session/scanClassification'; -import { - normalizeScanResult, - resolveScanDecodedUrl, - resolveScanIsUrl, - resolveScanRiskLevel, - resolveScanSchemeType, -} from '@/shared/lib/scan-session/scanResultNormalization'; import type { ResultTone } from '@/shared/types/resultTone'; +import { + buildAnalysisDetailPatch, + buildFinalResultPatch, + buildHistorySelectionPatch, + buildScanResponsePatch, +} from './scanSessionTransitions'; + export type ScanHistorySelection = { isUrl: boolean | null; riskLevel: ResultTone | null; @@ -118,68 +118,16 @@ export const useScanSessionStore = create()( }); }, setAnalysisDetail: (analysisDetail) => { - const normalizedResult = normalizeScanResult(analysisDetail); - - set((state) => { - const nextDecodedUrl = normalizedResult.decodedUrl ?? state.decodedUrl; - const schemeType = - normalizedResult.schemeType ?? resolveScanSchemeType(analysisDetail, nextDecodedUrl); - const nextSchemeType = schemeType ?? state.schemeType; - - return { - analysisDetail, - decodedUrl: nextDecodedUrl, - isUrl: resolveScanIsUrl(analysisDetail, nextDecodedUrl, nextSchemeType) ?? state.isUrl, - riskLevel: normalizedResult.riskLevel ?? state.riskLevel, - schemeType: nextSchemeType, - }; - }); + set((state) => buildAnalysisDetailPatch(state, analysisDetail)); }, setFinalResult: (finalResult) => { - const normalizedResult = normalizeScanResult(finalResult); - - set((state) => ({ - decodedUrl: normalizedResult.decodedUrl ?? state.decodedUrl, - finalResult, - historySelection: state.historySelection, - isUrl: - resolveScanIsUrl( - finalResult, - normalizedResult.decodedUrl ?? state.decodedUrl, - normalizedResult.schemeType ?? state.schemeType, - ) ?? state.isUrl, - riskLevel: normalizedResult.riskLevel ?? state.riskLevel, - schemeType: normalizedResult.schemeType ?? state.schemeType, - })); + set((state) => buildFinalResultPatch(state, finalResult)); }, setHistorySelection: (historySelection) => { - const schemeType = resolveScanSchemeType(historySelection, historySelection.url); - - set({ - analysisDetail: null, - decodedUrl: historySelection.url, - finalResult: null, - historySelection, - isUrl: resolveScanIsUrl(historySelection, historySelection.url, schemeType), - riskLevel: historySelection.riskLevel, - scanResponse: null, - schemeType, - }); + set(buildHistorySelectionPatch(historySelection)); }, setScanResponse: (scanResponse) => { - const decodedUrl = resolveScanDecodedUrl(scanResponse); - const schemeType = resolveScanSchemeType(scanResponse, decodedUrl); - - set({ - analysisDetail: null, - decodedUrl, - finalResult: null, - historySelection: null, - isUrl: resolveScanIsUrl(scanResponse, decodedUrl, schemeType), - riskLevel: resolveScanRiskLevel(scanResponse), - scanResponse, - schemeType, - }); + set(buildScanResponsePatch(scanResponse)); }, }), { diff --git a/src/shared/store/scanSessionTransitions.test.ts b/src/shared/store/scanSessionTransitions.test.ts new file mode 100644 index 0000000..98c4a43 --- /dev/null +++ b/src/shared/store/scanSessionTransitions.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it } from 'vitest'; + +import { + buildAnalysisDetailPatch, + buildFinalResultPatch, + buildHistorySelectionPatch, + buildScanResponsePatch, +} from './scanSessionTransitions'; + +import type { ScanHistorySelection, ScanSessionSnapshot } from './scanSessionStore'; + +function createSnapshot(overrides: Partial = {}): ScanSessionSnapshot { + return { + analysisDetail: null, + decodedUrl: 'https://previous.example/path', + finalResult: null, + historySelection: null, + isUrl: true, + riskLevel: 'warning', + scanResponse: null, + schemeType: 'WEB', + ...overrides, + }; +} + +describe('scanSessionTransitions', () => { + it('keeps previous URL classification when analysis detail omits URL fields', () => { + const patch = buildAnalysisDetailPatch(createSnapshot(), { + riskLevel: 'safe', + score: 72, + }); + + expect(patch).toMatchObject({ + decodedUrl: 'https://previous.example/path', + isUrl: true, + riskLevel: 'critical', + schemeType: 'WEB', + }); + }); + + it('keeps previous scan identity when final result only carries risk fields', () => { + const historySelection: ScanHistorySelection = { + isUrl: true, + riskLevel: 'warning', + scannedAt: '2026.05.14', + schemeType: 'WEB', + url: 'https://history.example/path', + }; + const patch = buildFinalResultPatch(createSnapshot({ historySelection }), { + riskLevel: 'critical', + }); + + expect(patch).toMatchObject({ + decodedUrl: 'https://previous.example/path', + historySelection, + isUrl: true, + riskLevel: 'critical', + schemeType: 'WEB', + }); + }); + + it('clears active scan payloads when selecting a history item', () => { + const historySelection: ScanHistorySelection = { + isUrl: false, + riskLevel: 'safe', + scannedAt: '2026.05.14', + schemeType: 'URL', + url: 'https://example.com/history', + }; + + expect(buildHistorySelectionPatch(historySelection)).toMatchObject({ + analysisDetail: null, + decodedUrl: 'https://example.com/history', + finalResult: null, + historySelection, + isUrl: true, + riskLevel: 'safe', + scanResponse: null, + schemeType: 'WEB', + }); + }); + + it('normalizes scan responses from backend typeInfo and score fields', () => { + const scanResponse = { + scheme_type: 'SMS', + score: 85, + typeInfo: '01012345678', + }; + + expect(buildScanResponsePatch(scanResponse)).toMatchObject({ + analysisDetail: null, + decodedUrl: '01012345678', + finalResult: null, + historySelection: null, + isUrl: false, + riskLevel: 'critical', + scanResponse, + schemeType: 'SMS', + }); + }); +}); diff --git a/src/shared/store/scanSessionTransitions.ts b/src/shared/store/scanSessionTransitions.ts new file mode 100644 index 0000000..e0624b4 --- /dev/null +++ b/src/shared/store/scanSessionTransitions.ts @@ -0,0 +1,83 @@ +import type { + BackendAnalysisDetailResponse, + BackendScanResponse, + BackendSseFinalPayload, +} from '@/shared/api/types'; +import { + normalizeScanResult, + resolveScanIsUrl, + resolveScanSchemeType, +} from '@/shared/lib/scan-session/scanResultNormalization'; + +import type { ScanHistorySelection, ScanSessionSnapshot } from './scanSessionStore'; + +export type ScanSessionPatch = Partial; + +export function buildAnalysisDetailPatch( + state: ScanSessionSnapshot, + analysisDetail: BackendAnalysisDetailResponse, +): ScanSessionPatch { + const normalizedResult = normalizeScanResult(analysisDetail); + const nextDecodedUrl = normalizedResult.decodedUrl ?? state.decodedUrl; + const schemeType = + normalizedResult.schemeType ?? resolveScanSchemeType(analysisDetail, nextDecodedUrl); + const nextSchemeType = schemeType ?? state.schemeType; + + return { + analysisDetail, + decodedUrl: nextDecodedUrl, + isUrl: resolveScanIsUrl(analysisDetail, nextDecodedUrl, nextSchemeType) ?? state.isUrl, + riskLevel: normalizedResult.riskLevel ?? state.riskLevel, + schemeType: nextSchemeType, + }; +} + +export function buildFinalResultPatch( + state: ScanSessionSnapshot, + finalResult: BackendSseFinalPayload, +): ScanSessionPatch { + const normalizedResult = normalizeScanResult(finalResult); + const nextDecodedUrl = normalizedResult.decodedUrl ?? state.decodedUrl; + const nextSchemeType = normalizedResult.schemeType ?? state.schemeType; + + return { + decodedUrl: nextDecodedUrl, + finalResult, + historySelection: state.historySelection, + isUrl: resolveScanIsUrl(finalResult, nextDecodedUrl, nextSchemeType) ?? state.isUrl, + riskLevel: normalizedResult.riskLevel ?? state.riskLevel, + schemeType: nextSchemeType, + }; +} + +export function buildHistorySelectionPatch( + historySelection: ScanHistorySelection, +): ScanSessionPatch { + const schemeType = resolveScanSchemeType(historySelection, historySelection.url); + + return { + analysisDetail: null, + decodedUrl: historySelection.url, + finalResult: null, + historySelection, + isUrl: resolveScanIsUrl(historySelection, historySelection.url, schemeType), + riskLevel: historySelection.riskLevel, + scanResponse: null, + schemeType, + }; +} + +export function buildScanResponsePatch(scanResponse: BackendScanResponse): ScanSessionPatch { + const normalizedResult = normalizeScanResult(scanResponse); + + return { + analysisDetail: null, + decodedUrl: normalizedResult.decodedUrl, + finalResult: null, + historySelection: null, + isUrl: normalizedResult.isUrl, + riskLevel: normalizedResult.riskLevel, + scanResponse, + schemeType: normalizedResult.schemeType, + }; +} From ad9554f7f562ac309e60c11147c26784d2c2f898 Mon Sep 17 00:00:00 2001 From: kimsubsub Date: Thu, 14 May 2026 22:04:58 +0900 Subject: [PATCH 18/27] =?UTF-8?q?docs:=20=EC=83=81=ED=83=9C=20=EA=B4=80?= =?UTF-8?q?=EB=A6=AC=20=EB=A6=AC=ED=8C=A9=ED=86=A0=EB=A7=81=20=EA=B8=B0?= =?UTF-8?q?=EB=A1=9D=20=EA=B0=B1=EC=8B=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/refactor-roadmap.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/refactor-roadmap.md b/docs/refactor-roadmap.md index afff1bf..4649e13 100644 --- a/docs/refactor-roadmap.md +++ b/docs/refactor-roadmap.md @@ -15,6 +15,7 @@ - 2026-05-14: P1 비 URL 실행 보안 1차 보강 완료. 실행 가능한 스킴을 제한하고, 실행 전 확인 단계를 추가했다. - 2026-05-14: P1 응답 정규화 1차 보강 완료. URL, 스키마, 웹 여부, 위험도 판정을 공통 유틸로 분리했다. - 2026-05-14: P1 URL 해석 1차 보강 완료. 결과/리포트 페이지의 스캔 URL, 원본 URL, 최종 URL 선택 기준을 공통화했다. +- 2026-05-14: P1 상태 관리 1차 보강 완료. `scanSessionStore`의 상태 전환 계산을 순수 함수로 분리하고 회귀 테스트를 추가했다. ## 관찰 결과 From d8a4ab1c656218eaf274dd9275d83b8f7515dd09 Mon Sep 17 00:00:00 2001 From: kimsubsub Date: Thu, 14 May 2026 22:10:46 +0900 Subject: [PATCH 19/27] =?UTF-8?q?refactor:=20=EB=A1=9C=EB=94=A9=20?= =?UTF-8?q?=EA=B2=B0=EA=B3=BC=20=EC=A0=84=ED=99=98=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/Loading/hooks/useLoadingPage.ts | 66 ++-------------- .../lib/loadingProgressResolution.test.ts | 37 +++++++++ .../Loading/lib/loadingProgressResolution.ts | 22 ++++++ src/pages/QRScan/hooks/useQRScanPage.ts | 41 +++------- .../lib/scan-session/scanIdentity.test.ts | 17 ++++ src/shared/lib/scan-session/scanIdentity.ts | 12 +++ .../lib/scan-session/scanResultRoute.test.ts | 58 ++++++++++++++ .../lib/scan-session/scanResultRoute.ts | 78 +++++++++++++++++++ 8 files changed, 240 insertions(+), 91 deletions(-) create mode 100644 src/pages/Loading/lib/loadingProgressResolution.test.ts create mode 100644 src/pages/Loading/lib/loadingProgressResolution.ts create mode 100644 src/shared/lib/scan-session/scanIdentity.test.ts create mode 100644 src/shared/lib/scan-session/scanIdentity.ts create mode 100644 src/shared/lib/scan-session/scanResultRoute.test.ts create mode 100644 src/shared/lib/scan-session/scanResultRoute.ts diff --git a/src/pages/Loading/hooks/useLoadingPage.ts b/src/pages/Loading/hooks/useLoadingPage.ts index aab3336..02cd657 100644 --- a/src/pages/Loading/hooks/useLoadingPage.ts +++ b/src/pages/Loading/hooks/useLoadingPage.ts @@ -3,16 +3,16 @@ import { App } from 'antd'; import { useCallback, useEffect, useRef } from 'react'; import { isApiError } from '@/shared/api/errors/apiError'; -import { pickString } from '@/shared/api/responseAccess/payloadAccess'; import { resolveResultToneFromSources } from '@/shared/api/risk/resolveResultTone'; -import { mapSseStepId } from '@/shared/api/sse/sseStepMapper'; import { ensureScanDetail } from '@/shared/lib/scan-session/ensureScanDetail'; -import { isWebScanTarget } from '@/shared/lib/scan-session/scanClassification'; +import { isSameScanSource } from '@/shared/lib/scan-session/scanIdentity'; +import { resolveScanResultRoute } from '@/shared/lib/scan-session/scanResultRoute'; import { useScanSubscription } from '@/shared/lib/sse/useScanSubscription'; import { useGuestStore } from '@/shared/store/guestStore'; import { useScanProgressStore } from '@/shared/store/scanProgressStore'; import { getScanSessionSnapshot, useScanSessionStore } from '@/shared/store/scanSessionStore'; +import { shouldResolveDetailAfterTerminalProgress } from '../lib/loadingProgressResolution'; import { getLoadingSteps } from '../loadingScenario'; import type { LoadingPageData } from '../types/loadingPage.types'; @@ -32,49 +32,7 @@ const DEFAULT_LOADING_PAGE_DATA: LoadingPageData = { }; function openResultRouteForCurrentSession() { - const session = getScanSessionSnapshot(); - const currentTargetUrl = session.decodedUrl ?? session.historySelection?.url ?? null; - - if ( - !isWebScanTarget({ - isUrl: session.isUrl, - schemeType: session.schemeType, - url: currentTargetUrl, - }) - ) { - window.location.assign('/result/non-url'); - return; - } - - const riskLevel = - resolveResultToneFromSources( - [session.analysisDetail, session.finalResult, session.scanResponse, session.historySelection], - session.riskLevel, - ) ?? 'warning'; - const routeByRiskLevel = { - critical: '/result/critical', - safe: '/result/safe', - warning: '/result/warning', - } as const; - const route = routeByRiskLevel[riskLevel]; - - if (currentTargetUrl) { - window.location.assign(`${route}?url=${encodeURIComponent(currentTargetUrl)}`); - return; - } - - window.location.assign(route); -} - -function resolveScanIdentifier(source: unknown): string | null { - return pickString(source, ['scanId', 'scan_id', 'id']); -} - -function isSameScanFinalResult(finalResult: unknown, scanResponse: unknown): boolean { - const finalResultId = resolveScanIdentifier(finalResult); - const scanResponseId = resolveScanIdentifier(scanResponse); - - return Boolean(finalResultId && scanResponseId && finalResultId === scanResponseId); + window.location.assign(resolveScanResultRoute(getScanSessionSnapshot()).href); } export function useLoadingPage(): UseLoadingPageReturn { @@ -108,7 +66,7 @@ export function useLoadingPage(): UseLoadingPageReturn { return; } - if (finalResult && scanResponse && isSameScanFinalResult(finalResult, scanResponse)) { + if (finalResult && scanResponse && isSameScanSource(finalResult, scanResponse)) { return; } @@ -180,19 +138,7 @@ export function useLoadingPage(): UseLoadingPageReturn { const tryResolveDetailAfterTerminalProgress = useCallback( async (payload: Record) => { - const rawStatus = pickString(payload, ['status', 'state'])?.trim().toLowerCase(); - const rawStep = pickString(payload, [ - 'currentStepId', - 'current_step_id', - 'step', - 'stepId', - 'step_id', - ]); - const mappedStepId = mapSseStepId(rawStep); - const isTerminalStep = - mappedStepId === 'riskScore' || mappedStepId === 'report' || mappedStepId === 'completed'; - - if (rawStatus !== 'completed' || !isTerminalStep) { + if (!shouldResolveDetailAfterTerminalProgress(payload)) { return; } diff --git a/src/pages/Loading/lib/loadingProgressResolution.test.ts b/src/pages/Loading/lib/loadingProgressResolution.test.ts new file mode 100644 index 0000000..8e8d45e --- /dev/null +++ b/src/pages/Loading/lib/loadingProgressResolution.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest'; + +import { shouldResolveDetailAfterTerminalProgress } from './loadingProgressResolution'; + +describe('loadingProgressResolution', () => { + it('detects completed terminal progress payloads', () => { + expect( + shouldResolveDetailAfterTerminalProgress({ + current_step_id: 'risk_score', + status: 'completed', + }), + ).toBe(true); + + expect( + shouldResolveDetailAfterTerminalProgress({ + state: 'completed', + step: 'report', + }), + ).toBe(true); + }); + + it('ignores unfinished or non-terminal progress payloads', () => { + expect( + shouldResolveDetailAfterTerminalProgress({ + status: 'running', + step: 'report', + }), + ).toBe(false); + + expect( + shouldResolveDetailAfterTerminalProgress({ + status: 'completed', + step: 'decode', + }), + ).toBe(false); + }); +}); diff --git a/src/pages/Loading/lib/loadingProgressResolution.ts b/src/pages/Loading/lib/loadingProgressResolution.ts new file mode 100644 index 0000000..6b49ddf --- /dev/null +++ b/src/pages/Loading/lib/loadingProgressResolution.ts @@ -0,0 +1,22 @@ +import { pickString } from '@/shared/api/responseAccess/payloadAccess'; +import { mapSseStepId } from '@/shared/api/sse/sseStepMapper'; + +const terminalDetailStepIds = new Set(['riskScore', 'report', 'completed']); + +export function shouldResolveDetailAfterTerminalProgress( + payload: Record, +): boolean { + const rawStatus = pickString(payload, ['status', 'state'])?.trim().toLowerCase(); + const rawStep = pickString(payload, [ + 'currentStepId', + 'current_step_id', + 'step', + 'stepId', + 'step_id', + ]); + const mappedStepId = mapSseStepId(rawStep); + + return ( + rawStatus === 'completed' && Boolean(mappedStepId && terminalDetailStepIds.has(mappedStepId)) + ); +} diff --git a/src/pages/QRScan/hooks/useQRScanPage.ts b/src/pages/QRScan/hooks/useQRScanPage.ts index 4bd91b3..83097cf 100644 --- a/src/pages/QRScan/hooks/useQRScanPage.ts +++ b/src/pages/QRScan/hooks/useQRScanPage.ts @@ -7,6 +7,11 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { showApiError } from '@/shared/lib/feedback/showApiError'; import { isWebScanTarget } from '@/shared/lib/scan-session/scanClassification'; import { normalizeScanResult } from '@/shared/lib/scan-session/scanResultNormalization'; +import { + buildScanResultHref, + nonUrlResultRoute, + resolveScanResultRouteByRiskLevel, +} from '@/shared/lib/scan-session/scanResultRoute'; import { useScanProgressStore } from '@/shared/store/scanProgressStore'; import { useScanSessionStore } from '@/shared/store/scanSessionStore'; @@ -14,10 +19,7 @@ import { fetchRecentScanHistoryData, getInitialScanHistoryData, } from '@/features/scan-history/api/fetchScanHistoryData'; -import type { - ScanHistoryItem, - ScanHistoryStatus, -} from '@/features/scan-history/types/scanHistory.types'; +import type { ScanHistoryItem } from '@/features/scan-history/types/scanHistory.types'; import { submitQrImage } from '@/features/scan-url/api/submitQrImage'; import { isCaptchaRequiredUploadError } from '@/features/scan-url/api/uploadErrors'; @@ -49,16 +51,6 @@ type UseQRScanPageReturn = { videoRef: RefObject; }; -type ResultRoute = '/result/critical' | '/result/non-url' | '/result/safe' | '/result/warning'; - -const nonUrlResultRoute = '/result/non-url'; - -const resultRouteByStatus: Record = { - safe: '/result/safe', - warning: '/result/warning', - critical: '/result/critical', -}; - function formatCapturedAt(date: Date) { const year = date.getFullYear(); const month = `${date.getMonth() + 1}`.padStart(2, '0'); @@ -144,19 +136,6 @@ function isNonWebScanResponse(scanResponse: Record): boolean { return !isWebScanTarget(normalizedResult); } -function isWebHistoryItem(item: ScanHistoryItem): boolean { - return isWebScanTarget(item); -} - -function openResultPage(route: ResultRoute, url: string) { - if (route === nonUrlResultRoute) { - window.location.assign(route); - return; - } - - window.location.assign(`${route}?url=${encodeURIComponent(url)}`); -} - export function useQRScanPage(): UseQRScanPageReturn { const { message } = App.useApp(); const navigate = useNavigate(); @@ -309,7 +288,7 @@ export function useQRScanPage(): UseQRScanPageReturn { message.success(onSuccessMessage); if (isNonWebScanResponse(scanResponse)) { - void navigate({ to: '/result/non-url' }); + void navigate({ to: nonUrlResultRoute }); return; } @@ -417,11 +396,11 @@ export function useQRScanPage(): UseQRScanPageReturn { schemeType: recentScanItem.schemeType, url: recentScanItem.url, }); - const route = isWebHistoryItem(recentScanItem) - ? resultRouteByStatus[recentScanItem.status] + const route = isWebScanTarget(recentScanItem) + ? resolveScanResultRouteByRiskLevel(recentScanItem.status) : nonUrlResultRoute; - openResultPage(route, recentScanItem.url); + window.location.assign(buildScanResultHref(route, recentScanItem.url)); }, [recentScanItem, setHistorySelection]); const handleCapturePhoto = useCallback(async () => { diff --git a/src/shared/lib/scan-session/scanIdentity.test.ts b/src/shared/lib/scan-session/scanIdentity.test.ts new file mode 100644 index 0000000..3c69c94 --- /dev/null +++ b/src/shared/lib/scan-session/scanIdentity.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'vitest'; + +import { isSameScanSource, resolveScanIdentifier } from './scanIdentity'; + +describe('scanIdentity', () => { + it('resolves common scan identifier fields', () => { + expect(resolveScanIdentifier({ scanId: 'scan-1' })).toBe('scan-1'); + expect(resolveScanIdentifier({ scan_id: 'scan-2' })).toBe('scan-2'); + expect(resolveScanIdentifier({ id: 'scan-3' })).toBe('scan-3'); + }); + + it('matches sources only when both identifiers exist and are equal', () => { + expect(isSameScanSource({ scanId: 'scan-1' }, { scan_id: 'scan-1' })).toBe(true); + expect(isSameScanSource({ scanId: 'scan-1' }, { scan_id: 'scan-2' })).toBe(false); + expect(isSameScanSource({ scanId: 'scan-1' }, {})).toBe(false); + }); +}); diff --git a/src/shared/lib/scan-session/scanIdentity.ts b/src/shared/lib/scan-session/scanIdentity.ts new file mode 100644 index 0000000..8f256ff --- /dev/null +++ b/src/shared/lib/scan-session/scanIdentity.ts @@ -0,0 +1,12 @@ +import { pickString } from '@/shared/api/responseAccess/payloadAccess'; + +export function resolveScanIdentifier(source: unknown): string | null { + return pickString(source, ['scanId', 'scan_id', 'id']); +} + +export function isSameScanSource(left: unknown, right: unknown): boolean { + const leftId = resolveScanIdentifier(left); + const rightId = resolveScanIdentifier(right); + + return Boolean(leftId && rightId && leftId === rightId); +} diff --git a/src/shared/lib/scan-session/scanResultRoute.test.ts b/src/shared/lib/scan-session/scanResultRoute.test.ts new file mode 100644 index 0000000..5e437c3 --- /dev/null +++ b/src/shared/lib/scan-session/scanResultRoute.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'vitest'; + +import { + buildScanResultHref, + nonUrlResultRoute, + resolveScanResultRoute, + resolveScanResultRouteByRiskLevel, +} from './scanResultRoute'; + +describe('scanResultRoute', () => { + it('builds encoded web result hrefs', () => { + expect(buildScanResultHref('/result/critical', 'https://example.com/a b?x=1')).toBe( + '/result/critical?url=https%3A%2F%2Fexample.com%2Fa%20b%3Fx%3D1', + ); + }); + + it('keeps non-url result routes free from URL query strings', () => { + expect(buildScanResultHref(nonUrlResultRoute, 'tel:01012345678')).toBe(nonUrlResultRoute); + }); + + it('resolves non-web scans to the non-url result route', () => { + expect( + resolveScanResultRoute({ + decodedUrl: '01012345678', + isUrl: false, + riskLevel: 'critical', + schemeType: 'SMS', + }), + ).toMatchObject({ + href: nonUrlResultRoute, + route: nonUrlResultRoute, + url: '01012345678', + }); + }); + + it('uses scan score before fallback risk level for web routes', () => { + expect( + resolveScanResultRoute({ + decodedUrl: 'https://example.com', + finalResult: { + score: 85, + }, + isUrl: true, + riskLevel: 'safe', + schemeType: 'WEB', + }), + ).toMatchObject({ + href: '/result/critical?url=https%3A%2F%2Fexample.com', + route: '/result/critical', + }); + }); + + it('maps risk levels to web result routes', () => { + expect(resolveScanResultRouteByRiskLevel('safe')).toBe('/result/safe'); + expect(resolveScanResultRouteByRiskLevel('warning')).toBe('/result/warning'); + expect(resolveScanResultRouteByRiskLevel('critical')).toBe('/result/critical'); + }); +}); diff --git a/src/shared/lib/scan-session/scanResultRoute.ts b/src/shared/lib/scan-session/scanResultRoute.ts new file mode 100644 index 0000000..2562172 --- /dev/null +++ b/src/shared/lib/scan-session/scanResultRoute.ts @@ -0,0 +1,78 @@ +import { resolveResultToneFromSources } from '@/shared/api/risk/resolveResultTone'; +import type { ResultTone } from '@/shared/types/resultTone'; + +import { isWebScanTarget } from './scanClassification'; + +export type ScanResultRoute = + | '/result/critical' + | '/result/non-url' + | '/result/safe' + | '/result/warning'; + +export type WebScanResultRoute = Exclude; + +type ScanResultRouteSource = { + analysisDetail?: unknown; + decodedUrl?: string | null; + finalResult?: unknown; + historySelection?: { url?: string | null } | null; + isUrl?: boolean | null; + riskLevel?: ResultTone | null; + scanResponse?: unknown; + schemeType?: string | null; +}; + +export const nonUrlResultRoute = '/result/non-url' as const; + +const resultRouteByRiskLevel: Record = { + critical: '/result/critical', + safe: '/result/safe', + warning: '/result/warning', +}; + +export function buildScanResultHref(route: ScanResultRoute, url: string | null): string { + if (route === nonUrlResultRoute || !url) { + return route; + } + + return `${route}?url=${encodeURIComponent(url)}`; +} + +export function resolveScanResultRouteByRiskLevel(riskLevel: ResultTone): WebScanResultRoute { + return resultRouteByRiskLevel[riskLevel]; +} + +export function resolveScanResultRoute(source: ScanResultRouteSource): { + href: string; + route: ScanResultRoute; + url: string | null; +} { + const currentTargetUrl = source.decodedUrl ?? source.historySelection?.url ?? null; + + if ( + !isWebScanTarget({ + isUrl: source.isUrl ?? null, + schemeType: source.schemeType ?? null, + url: currentTargetUrl, + }) + ) { + return { + href: nonUrlResultRoute, + route: nonUrlResultRoute, + url: currentTargetUrl, + }; + } + + const riskLevel = + resolveResultToneFromSources( + [source.analysisDetail, source.finalResult, source.scanResponse, source.historySelection], + source.riskLevel, + ) ?? 'warning'; + const route = resolveScanResultRouteByRiskLevel(riskLevel); + + return { + href: buildScanResultHref(route, currentTargetUrl), + route, + url: currentTargetUrl, + }; +} From bbce837e817f5b2e7dc04b0a22176d91e65eed85 Mon Sep 17 00:00:00 2001 From: kimsubsub Date: Thu, 14 May 2026 22:10:58 +0900 Subject: [PATCH 20/27] =?UTF-8?q?docs:=20=EB=A1=9C=EB=94=A9=20SSE=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC=20=EA=B8=B0=EB=A1=9D=20=EA=B0=B1=EC=8B=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/refactor-roadmap.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/refactor-roadmap.md b/docs/refactor-roadmap.md index 4649e13..720ebe9 100644 --- a/docs/refactor-roadmap.md +++ b/docs/refactor-roadmap.md @@ -16,6 +16,7 @@ - 2026-05-14: P1 응답 정규화 1차 보강 완료. URL, 스키마, 웹 여부, 위험도 판정을 공통 유틸로 분리했다. - 2026-05-14: P1 URL 해석 1차 보강 완료. 결과/리포트 페이지의 스캔 URL, 원본 URL, 최종 URL 선택 기준을 공통화했다. - 2026-05-14: P1 상태 관리 1차 보강 완료. `scanSessionStore`의 상태 전환 계산을 순수 함수로 분리하고 회귀 테스트를 추가했다. +- 2026-05-14: P2 로딩/SSE 1차 분리 완료. 결과 라우트 결정, 스캔 식별자 비교, 완료 진행 이벤트 판정을 순수 함수로 분리했다. ## 관찰 결과 From 91d32d2b36470550ba025b7e535f2de5be6b6942 Mon Sep 17 00:00:00 2001 From: kimsubsub Date: Thu, 14 May 2026 22:15:41 +0900 Subject: [PATCH 21/27] =?UTF-8?q?refactor:=20=EB=A1=9C=EB=94=A9=20?= =?UTF-8?q?=EC=83=81=EC=84=B8=20=EC=A1=B0=ED=9A=8C=20=EC=A0=95=EC=B1=85=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/Loading/hooks/useLoadingPage.ts | 108 ++++------------- .../Loading/lib/loadingDetailPolling.test.ts | 95 +++++++++++++++ src/pages/Loading/lib/loadingDetailPolling.ts | 75 ++++++++++++ .../lib/loadingDetailResolution.test.ts | 110 ++++++++++++++++++ .../Loading/lib/loadingDetailResolution.ts | 75 ++++++++++++ 5 files changed, 375 insertions(+), 88 deletions(-) create mode 100644 src/pages/Loading/lib/loadingDetailPolling.test.ts create mode 100644 src/pages/Loading/lib/loadingDetailPolling.ts create mode 100644 src/pages/Loading/lib/loadingDetailResolution.test.ts create mode 100644 src/pages/Loading/lib/loadingDetailResolution.ts diff --git a/src/pages/Loading/hooks/useLoadingPage.ts b/src/pages/Loading/hooks/useLoadingPage.ts index 02cd657..611d6fa 100644 --- a/src/pages/Loading/hooks/useLoadingPage.ts +++ b/src/pages/Loading/hooks/useLoadingPage.ts @@ -2,7 +2,6 @@ import { useNavigate } from '@tanstack/react-router'; import { App } from 'antd'; import { useCallback, useEffect, useRef } from 'react'; -import { isApiError } from '@/shared/api/errors/apiError'; import { resolveResultToneFromSources } from '@/shared/api/risk/resolveResultTone'; import { ensureScanDetail } from '@/shared/lib/scan-session/ensureScanDetail'; import { isSameScanSource } from '@/shared/lib/scan-session/scanIdentity'; @@ -12,6 +11,11 @@ import { useGuestStore } from '@/shared/store/guestStore'; import { useScanProgressStore } from '@/shared/store/scanProgressStore'; import { getScanSessionSnapshot, useScanSessionStore } from '@/shared/store/scanSessionStore'; +import { startLoadingDetailPolling } from '../lib/loadingDetailPolling'; +import { + DETAIL_RESOLUTION_TIMEOUT_MESSAGE, + resolveLoadingDetailWithRetry, +} from '../lib/loadingDetailResolution'; import { shouldResolveDetailAfterTerminalProgress } from '../lib/loadingProgressResolution'; import { getLoadingSteps } from '../loadingScenario'; @@ -21,12 +25,6 @@ type UseLoadingPageReturn = { loadingPageData: LoadingPageData; }; -const DETAIL_RETRY_DELAY_MS = 2_000; -const DETAIL_RETRY_MAX = 60; -const DETAIL_POLL_MAX_ATTEMPTS = 1; -const DETAIL_POLL_START_DELAY_MS = 5_000; -const DETAIL_RESOLUTION_ERROR_MESSAGE = 'Analysis detail could not be loaded. Please try again.'; -const DETAIL_RESOLUTION_TIMEOUT_MESSAGE = 'Analysis detail was not ready in time. Please retry.'; const DEFAULT_LOADING_PAGE_DATA: LoadingPageData = { steps: getLoadingSteps(), }; @@ -50,7 +48,6 @@ export function useLoadingPage(): UseLoadingPageReturn { const detailResolutionFailedRef = useRef(false); const detailResolutionStartedRef = useRef(false); const isMountedRef = useRef(true); - const pollAttemptCountRef = useRef(0); useEffect(() => { isMountedRef.current = true; @@ -73,7 +70,6 @@ export function useLoadingPage(): UseLoadingPageReturn { resetProgress(); detailResolutionFailedRef.current = false; detailResolutionStartedRef.current = false; - pollAttemptCountRef.current = 0; setConnecting(); }, [finalResult, navigate, resetProgress, scanResponse, setConnecting]); @@ -98,42 +94,21 @@ export function useLoadingPage(): UseLoadingPageReturn { detailResolutionStartedRef.current = true; - for (let attempt = 0; attempt < DETAIL_RETRY_MAX; attempt += 1) { - try { - await ensureScanDetail(); - - if (!isMountedRef.current) { - return false; - } - + const resolutionStatus = await resolveLoadingDetailWithRetry({ + ensureDetail: ensureScanDetail, + isActive: () => isMountedRef.current, + onFailure: failDetailResolution, + onResolved: () => { setCompleted(); openResultRouteForCurrentSession(); - return true; - } catch (error) { - if (!isMountedRef.current) { - return false; - } - - if (error instanceof Error && error.message === 'SCAN_SESSION_REQUIRED') { - detailResolutionStartedRef.current = false; - return false; - } - - if (!isApiError(error) || error.statusCode !== 404) { - failDetailResolution( - error instanceof Error ? error.message : DETAIL_RESOLUTION_ERROR_MESSAGE, - ); - throw error; - } + }, + }); - await new Promise((resolve) => { - window.setTimeout(() => resolve(), DETAIL_RETRY_DELAY_MS); - }); - } + if (resolutionStatus === 'session-required') { + detailResolutionStartedRef.current = false; } - failDetailResolution(DETAIL_RESOLUTION_TIMEOUT_MESSAGE); - return false; + return resolutionStatus === 'resolved'; }, [failDetailResolution, setCompleted]); const tryResolveDetailAfterTerminalProgress = useCallback( @@ -162,54 +137,11 @@ export function useLoadingPage(): UseLoadingPageReturn { return; } - let isDisposed = false; - let pollTimerId: number | null = null; - - const startPolling = async () => { - if ( - isDisposed || - detailResolutionFailedRef.current || - detailResolutionStartedRef.current || - pollAttemptCountRef.current >= DETAIL_POLL_MAX_ATTEMPTS - ) { - return; - } - - pollAttemptCountRef.current += 1; - - let isResolved = false; - - try { - isResolved = await resolveDetailAndOpenResult(); - } catch { - return; - } - - if (isDisposed || isResolved || detailResolutionFailedRef.current) { - return; - } - - if (pollAttemptCountRef.current >= DETAIL_POLL_MAX_ATTEMPTS) { - failDetailResolution(DETAIL_RESOLUTION_TIMEOUT_MESSAGE); - return; - } - - pollTimerId = window.setTimeout(() => { - void startPolling(); - }, DETAIL_POLL_START_DELAY_MS); - }; - - pollTimerId = window.setTimeout(() => { - void startPolling(); - }, DETAIL_POLL_START_DELAY_MS); - - return () => { - isDisposed = true; - - if (pollTimerId !== null) { - window.clearTimeout(pollTimerId); - } - }; + return startLoadingDetailPolling({ + canPoll: () => !detailResolutionFailedRef.current && !detailResolutionStartedRef.current, + onTimeout: () => failDetailResolution(DETAIL_RESOLUTION_TIMEOUT_MESSAGE), + resolveDetail: resolveDetailAndOpenResult, + }); }, [failDetailResolution, finalResult, resolveDetailAndOpenResult, scanResponse]); useScanSubscription({ diff --git a/src/pages/Loading/lib/loadingDetailPolling.test.ts b/src/pages/Loading/lib/loadingDetailPolling.test.ts new file mode 100644 index 0000000..aac2917 --- /dev/null +++ b/src/pages/Loading/lib/loadingDetailPolling.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { startLoadingDetailPolling } from './loadingDetailPolling'; + +function createManualTimer() { + const callbacks: Array<() => void> = []; + + return { + callbacks, + clearTimer: vi.fn(), + scheduleTimer: vi.fn((callback: () => void) => { + callbacks.push(callback); + return callbacks.length; + }), + }; +} + +describe('loadingDetailPolling', () => { + it('schedules detail resolution after the configured delay', async () => { + const timer = createManualTimer(); + const resolveDetail = vi.fn().mockResolvedValue(true); + + startLoadingDetailPolling({ + canPoll: () => true, + clearTimer: timer.clearTimer, + onTimeout: vi.fn(), + resolveDetail, + scheduleTimer: timer.scheduleTimer, + startDelayMs: 25, + }); + + expect(timer.scheduleTimer).toHaveBeenCalledWith(expect.any(Function), 25); + + timer.callbacks[0]?.(); + await Promise.resolve(); + + expect(resolveDetail).toHaveBeenCalledTimes(1); + }); + + it('reports timeout when polling exhausts the attempt limit without resolving', async () => { + const timer = createManualTimer(); + const onTimeout = vi.fn(); + + startLoadingDetailPolling({ + canPoll: () => true, + clearTimer: timer.clearTimer, + maxAttempts: 1, + onTimeout, + resolveDetail: vi.fn().mockResolvedValue(false), + scheduleTimer: timer.scheduleTimer, + }); + + timer.callbacks[0]?.(); + await Promise.resolve(); + + expect(onTimeout).toHaveBeenCalledTimes(1); + }); + + it('stops polling when disposed before the timer runs', async () => { + const timer = createManualTimer(); + const resolveDetail = vi.fn().mockResolvedValue(true); + const dispose = startLoadingDetailPolling({ + canPoll: () => true, + clearTimer: timer.clearTimer, + onTimeout: vi.fn(), + resolveDetail, + scheduleTimer: timer.scheduleTimer, + }); + + dispose(); + timer.callbacks[0]?.(); + await Promise.resolve(); + + expect(timer.clearTimer).toHaveBeenCalledWith(1); + expect(resolveDetail).not.toHaveBeenCalled(); + }); + + it('does not poll while the caller marks polling as unavailable', async () => { + const timer = createManualTimer(); + const resolveDetail = vi.fn().mockResolvedValue(true); + + startLoadingDetailPolling({ + canPoll: () => false, + clearTimer: timer.clearTimer, + onTimeout: vi.fn(), + resolveDetail, + scheduleTimer: timer.scheduleTimer, + }); + + timer.callbacks[0]?.(); + await Promise.resolve(); + + expect(resolveDetail).not.toHaveBeenCalled(); + }); +}); diff --git a/src/pages/Loading/lib/loadingDetailPolling.ts b/src/pages/Loading/lib/loadingDetailPolling.ts new file mode 100644 index 0000000..a02c569 --- /dev/null +++ b/src/pages/Loading/lib/loadingDetailPolling.ts @@ -0,0 +1,75 @@ +export const DETAIL_POLL_MAX_ATTEMPTS = 1; +export const DETAIL_POLL_START_DELAY_MS = 5_000; + +type LoadingDetailPollingParams = { + canPoll: () => boolean; + clearTimer?: (timerId: number) => void; + maxAttempts?: number; + onTimeout: () => void; + resolveDetail: () => Promise; + scheduleTimer?: (callback: () => void, delayMs: number) => number; + startDelayMs?: number; +}; + +function clearTimeoutTimer(timerId: number): void { + window.clearTimeout(timerId); +} + +function scheduleTimeoutTimer(callback: () => void, delayMs: number): number { + return window.setTimeout(callback, delayMs); +} + +export function startLoadingDetailPolling({ + canPoll, + clearTimer = clearTimeoutTimer, + maxAttempts = DETAIL_POLL_MAX_ATTEMPTS, + onTimeout, + resolveDetail, + scheduleTimer = scheduleTimeoutTimer, + startDelayMs = DETAIL_POLL_START_DELAY_MS, +}: LoadingDetailPollingParams): () => void { + let isDisposed = false; + let pollAttemptCount = 0; + let pollTimerId: number | null = null; + + const runPolling = async () => { + if (isDisposed || !canPoll() || pollAttemptCount >= maxAttempts) { + return; + } + + pollAttemptCount += 1; + + let isResolved = false; + + try { + isResolved = await resolveDetail(); + } catch { + return; + } + + if (isDisposed || isResolved || !canPoll()) { + return; + } + + if (pollAttemptCount >= maxAttempts) { + onTimeout(); + return; + } + + pollTimerId = scheduleTimer(() => { + void runPolling(); + }, startDelayMs); + }; + + pollTimerId = scheduleTimer(() => { + void runPolling(); + }, startDelayMs); + + return () => { + isDisposed = true; + + if (pollTimerId !== null) { + clearTimer(pollTimerId); + } + }; +} diff --git a/src/pages/Loading/lib/loadingDetailResolution.test.ts b/src/pages/Loading/lib/loadingDetailResolution.test.ts new file mode 100644 index 0000000..dd287be --- /dev/null +++ b/src/pages/Loading/lib/loadingDetailResolution.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { ApiError } from '@/shared/api/errors/apiError'; + +import { + DETAIL_RESOLUTION_TIMEOUT_MESSAGE, + resolveLoadingDetailWithRetry, +} from './loadingDetailResolution'; + +describe('loadingDetailResolution', () => { + it('resolves detail immediately when the backend detail is available', async () => { + const onResolved = vi.fn(); + const onFailure = vi.fn(); + + await expect( + resolveLoadingDetailWithRetry({ + ensureDetail: vi.fn().mockResolvedValue({}), + isActive: () => true, + onFailure, + onResolved, + retryMax: 1, + }), + ).resolves.toBe('resolved'); + + expect(onResolved).toHaveBeenCalledTimes(1); + expect(onFailure).not.toHaveBeenCalled(); + }); + + it('retries 404 detail responses before resolving', async () => { + const delay = vi.fn().mockResolvedValue(undefined); + const ensureDetail = vi + .fn() + .mockRejectedValueOnce( + new ApiError({ + message: 'Not found', + statusCode: 404, + }), + ) + .mockResolvedValue({}); + + await expect( + resolveLoadingDetailWithRetry({ + delay, + ensureDetail, + isActive: () => true, + onFailure: vi.fn(), + onResolved: vi.fn(), + retryDelayMs: 25, + retryMax: 2, + }), + ).resolves.toBe('resolved'); + + expect(delay).toHaveBeenCalledWith(25); + expect(ensureDetail).toHaveBeenCalledTimes(2); + }); + + it('returns session-required without treating it as a failed detail lookup', async () => { + const onFailure = vi.fn(); + + await expect( + resolveLoadingDetailWithRetry({ + ensureDetail: vi.fn().mockRejectedValue(new Error('SCAN_SESSION_REQUIRED')), + isActive: () => true, + onFailure, + onResolved: vi.fn(), + retryMax: 1, + }), + ).resolves.toBe('session-required'); + + expect(onFailure).not.toHaveBeenCalled(); + }); + + it('fails and rethrows non-404 errors', async () => { + const onFailure = vi.fn(); + + await expect( + resolveLoadingDetailWithRetry({ + ensureDetail: vi.fn().mockRejectedValue(new Error('network failed')), + isActive: () => true, + onFailure, + onResolved: vi.fn(), + retryMax: 1, + }), + ).rejects.toThrow('network failed'); + + expect(onFailure).toHaveBeenCalledWith('network failed'); + }); + + it('times out after retryable 404 responses exceed the retry limit', async () => { + const onFailure = vi.fn(); + + await expect( + resolveLoadingDetailWithRetry({ + delay: vi.fn().mockResolvedValue(undefined), + ensureDetail: vi.fn().mockRejectedValue( + new ApiError({ + message: 'Not found', + statusCode: 404, + }), + ), + isActive: () => true, + onFailure, + onResolved: vi.fn(), + retryMax: 2, + }), + ).resolves.toBe('timeout'); + + expect(onFailure).toHaveBeenCalledWith(DETAIL_RESOLUTION_TIMEOUT_MESSAGE); + }); +}); diff --git a/src/pages/Loading/lib/loadingDetailResolution.ts b/src/pages/Loading/lib/loadingDetailResolution.ts new file mode 100644 index 0000000..0361fc5 --- /dev/null +++ b/src/pages/Loading/lib/loadingDetailResolution.ts @@ -0,0 +1,75 @@ +import { isApiError } from '@/shared/api/errors/apiError'; + +export const DETAIL_RETRY_DELAY_MS = 2_000; +export const DETAIL_RETRY_MAX = 60; +export const DETAIL_RESOLUTION_ERROR_MESSAGE = + 'Analysis detail could not be loaded. Please try again.'; +export const DETAIL_RESOLUTION_TIMEOUT_MESSAGE = + 'Analysis detail was not ready in time. Please retry.'; + +export type LoadingDetailResolutionStatus = + | 'inactive' + | 'resolved' + | 'session-required' + | 'timeout'; + +type ResolveLoadingDetailWithRetryParams = { + delay?: (delayMs: number) => Promise; + ensureDetail: () => Promise; + isActive: () => boolean; + onFailure: (errorMessage: string) => void; + onResolved: () => void; + retryDelayMs?: number; + retryMax?: number; +}; + +function wait(delayMs: number): Promise { + return new Promise((resolve) => { + window.setTimeout(() => resolve(), delayMs); + }); +} + +export async function resolveLoadingDetailWithRetry({ + delay = wait, + ensureDetail, + isActive, + onFailure, + onResolved, + retryDelayMs = DETAIL_RETRY_DELAY_MS, + retryMax = DETAIL_RETRY_MAX, +}: ResolveLoadingDetailWithRetryParams): Promise { + for (let attempt = 0; attempt < retryMax; attempt += 1) { + if (!isActive()) { + return 'inactive'; + } + + try { + await ensureDetail(); + + if (!isActive()) { + return 'inactive'; + } + + onResolved(); + return 'resolved'; + } catch (error) { + if (!isActive()) { + return 'inactive'; + } + + if (error instanceof Error && error.message === 'SCAN_SESSION_REQUIRED') { + return 'session-required'; + } + + if (!isApiError(error) || error.statusCode !== 404) { + onFailure(error instanceof Error ? error.message : DETAIL_RESOLUTION_ERROR_MESSAGE); + throw error; + } + + await delay(retryDelayMs); + } + } + + onFailure(DETAIL_RESOLUTION_TIMEOUT_MESSAGE); + return 'timeout'; +} From 68329a167d309f130bac69135bd0526f09d8101e Mon Sep 17 00:00:00 2001 From: kimsubsub Date: Thu, 14 May 2026 22:15:55 +0900 Subject: [PATCH 22/27] =?UTF-8?q?docs:=20=EB=A1=9C=EB=94=A9=20=EC=83=81?= =?UTF-8?q?=EC=84=B8=20=EC=A1=B0=ED=9A=8C=20=EB=B6=84=EB=A6=AC=20=EA=B8=B0?= =?UTF-8?q?=EB=A1=9D=20=EA=B0=B1=EC=8B=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/refactor-roadmap.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/refactor-roadmap.md b/docs/refactor-roadmap.md index 720ebe9..3882261 100644 --- a/docs/refactor-roadmap.md +++ b/docs/refactor-roadmap.md @@ -17,6 +17,7 @@ - 2026-05-14: P1 URL 해석 1차 보강 완료. 결과/리포트 페이지의 스캔 URL, 원본 URL, 최종 URL 선택 기준을 공통화했다. - 2026-05-14: P1 상태 관리 1차 보강 완료. `scanSessionStore`의 상태 전환 계산을 순수 함수로 분리하고 회귀 테스트를 추가했다. - 2026-05-14: P2 로딩/SSE 1차 분리 완료. 결과 라우트 결정, 스캔 식별자 비교, 완료 진행 이벤트 판정을 순수 함수로 분리했다. +- 2026-05-14: P2 로딩 상세 조회 1차 분리 완료. 상세 조회 재시도와 polling 타이머 정책을 테스트 가능한 유틸로 분리했다. ## 관찰 결과 From 87235eb720ad2f1b91806adcc6d9e0ccc4b0a0f5 Mon Sep 17 00:00:00 2001 From: kimsubsub Date: Thu, 14 May 2026 22:28:10 +0900 Subject: [PATCH 23/27] =?UTF-8?q?refactor:=20=EB=A6=AC=ED=8F=AC=ED=8A=B8?= =?UTF-8?q?=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EC=84=B9=EC=85=98=20=EB=B9=8C?= =?UTF-8?q?=EB=8D=94=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Report/lib/reportPageDataContext.test.ts | 79 +++++++++++ src/pages/Report/lib/reportPageDataContext.ts | 53 ++++++++ .../Report/lib/reportReputationData.test.ts | 47 +++++++ src/pages/Report/lib/reportReputationData.ts | 90 ++++++++++++ src/pages/Report/lib/toReportPageData.ts | 128 ++---------------- 5 files changed, 282 insertions(+), 115 deletions(-) create mode 100644 src/pages/Report/lib/reportPageDataContext.test.ts create mode 100644 src/pages/Report/lib/reportPageDataContext.ts create mode 100644 src/pages/Report/lib/reportReputationData.test.ts create mode 100644 src/pages/Report/lib/reportReputationData.ts diff --git a/src/pages/Report/lib/reportPageDataContext.test.ts b/src/pages/Report/lib/reportPageDataContext.test.ts new file mode 100644 index 0000000..70d9b25 --- /dev/null +++ b/src/pages/Report/lib/reportPageDataContext.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from 'vitest'; + +import type { ScanSessionSnapshot } from '@/shared/store/scanSessionStore'; + +import { createReportPageDataContext, getReportSessionSources } from './reportPageDataContext'; + +function createSession(overrides: Partial = {}): ScanSessionSnapshot { + return { + analysisDetail: null, + decodedUrl: null, + finalResult: null, + historySelection: null, + isUrl: true, + riskLevel: null, + scanResponse: null, + schemeType: 'WEB', + ...overrides, + }; +} + +describe('reportPageDataContext', () => { + it('keeps report session sources in priority order', () => { + const session = createSession({ + analysisDetail: { source: 'analysis' }, + finalResult: { source: 'final' }, + historySelection: { + isUrl: true, + riskLevel: 'safe', + scannedAt: '2026.05.14', + schemeType: 'WEB', + url: 'https://history.example', + }, + scanResponse: { source: 'scan' }, + }); + + expect(getReportSessionSources(session)).toEqual([ + session.analysisDetail, + session.finalResult, + session.scanResponse, + session.historySelection, + ]); + }); + + it('resolves risk, urls, and section records from backend payloads', () => { + const context = createReportPageDataContext( + createSession({ + analysisDetail: { + domainComparison: { + summary: 'domain mismatch', + }, + externalApi: { + provider: 'Google Safe Browsing', + }, + internalDb: { + reportCount: 2, + }, + originalUrl: 'https://short.example/a', + redirect: { + finalUrl: 'https://final.example/path', + }, + score: 85, + serverInfo: { + location: 'Seoul, KR', + }, + }, + decodedUrl: 'https://fallback.example', + riskLevel: 'safe', + }), + ); + + expect(context.riskLevel).toBe('critical'); + expect(context.urls.scannedUrl).toBe('https://short.example/a'); + expect(context.urls.destinationUrl).toBe('https://final.example/path'); + expect(context.domainComparisonRecord).toMatchObject({ summary: 'domain mismatch' }); + expect(context.internalDbRecord).toMatchObject({ reportCount: 2 }); + expect(context.reputationRecord).toMatchObject({ provider: 'Google Safe Browsing' }); + expect(context.serverInfoRecord).toMatchObject({ location: 'Seoul, KR' }); + }); +}); diff --git a/src/pages/Report/lib/reportPageDataContext.ts b/src/pages/Report/lib/reportPageDataContext.ts new file mode 100644 index 0000000..ea0fbc0 --- /dev/null +++ b/src/pages/Report/lib/reportPageDataContext.ts @@ -0,0 +1,53 @@ +import { pickSourceRecord } from '@/shared/api/responseAccess/payloadAccess'; +import { resolveResultToneFromSources } from '@/shared/api/risk/resolveResultTone'; +import { + resolveScanUrls, + type ResolvedScanUrls, +} from '@/shared/lib/scan-session/scanUrlResolution'; +import type { ScanSessionSnapshot } from '@/shared/store/scanSessionStore'; +import type { ResultTone } from '@/shared/types/resultTone'; + +export type ReportPageDataContext = { + domainComparisonRecord: Record | null; + internalDbRecord: Record | null; + reputationRecord: Record | null; + riskLevel: ResultTone; + serverInfoRecord: Record | null; + sources: unknown[]; + urls: ResolvedScanUrls; +}; + +export function getReportSessionSources(session: ScanSessionSnapshot): unknown[] { + return [ + session.analysisDetail, + session.finalResult, + session.scanResponse, + session.historySelection, + ]; +} + +export function createReportPageDataContext(session: ScanSessionSnapshot): ReportPageDataContext { + const sources = getReportSessionSources(session); + const riskLevel = resolveResultToneFromSources(sources, session.riskLevel) ?? 'warning'; + const urls = resolveScanUrls({ + decodedUrl: session.decodedUrl, + historyScannedAt: session.historySelection?.scannedAt, + historyUrl: session.historySelection?.url, + sources, + }); + + return { + domainComparisonRecord: pickSourceRecord(sources, ['domainComparison', 'domain_compare']), + internalDbRecord: pickSourceRecord(sources, ['internalDb', 'internal_db']), + reputationRecord: pickSourceRecord(sources, [ + 'reputation', + 'reputationSummary', + 'externalApi', + 'external_api', + ]), + riskLevel, + serverInfoRecord: pickSourceRecord(sources, ['serverInfo', 'server_info']), + sources, + urls, + }; +} diff --git a/src/pages/Report/lib/reportReputationData.test.ts b/src/pages/Report/lib/reportReputationData.test.ts new file mode 100644 index 0000000..d9e99a5 --- /dev/null +++ b/src/pages/Report/lib/reportReputationData.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest'; + +import { buildReportReputation } from './reportReputationData'; +import { missingReportInfoLabel } from '../constants/reportText'; + +describe('reportReputationData', () => { + it('prefers reputation summary metrics for report count and domain age', () => { + expect( + buildReportReputation( + { + provider: 'AlienVault OTX', + result: 'SAFE', + summary: { + domainAge: '4055', + reportCount: 3, + }, + }, + { + reportCount: 1, + }, + [], + ), + ).toMatchObject({ + providerName: 'AlienVault OTX', + providerStatusText: '검사 결과: SAFE', + summary: { + domainAgeText: '4,055일', + reportCount: 3, + }, + }); + }); + + it('falls back to internal DB metrics and handles missing domain age', () => { + expect( + buildReportReputation( + null, + { + report_count: 2, + }, + [], + ).summary, + ).toEqual({ + domainAgeText: missingReportInfoLabel, + reportCount: 2, + }); + }); +}); diff --git a/src/pages/Report/lib/reportReputationData.ts b/src/pages/Report/lib/reportReputationData.ts new file mode 100644 index 0000000..f68c8b7 --- /dev/null +++ b/src/pages/Report/lib/reportReputationData.ts @@ -0,0 +1,90 @@ +import { + asRecord, + pickRecord, + pickSourceNumber, + pickSourceString, +} from '@/shared/api/responseAccess/payloadAccess'; + +import { missingReportInfoLabel } from '../constants/reportText'; + +import type { ReportPageData } from '../types/reportPage.types'; + +function clampCount(value: number | null): number { + return value === null ? 0 : Math.max(0, Math.round(value)); +} + +function resolveExternalApiStatus( + externalApiRecord: Record | null, +): string | null { + const result = pickSourceString([externalApiRecord], ['result', 'status']); + + if (!result) { + return null; + } + + return `검사 결과: ${result}`; +} + +function resolveSummaryCount(sources: unknown[], keys: string[]): number { + return clampCount(pickSourceNumber(sources, keys)); +} + +function formatDomainAgeText(rawValue: string | number | null): string { + if (rawValue === null) { + return missingReportInfoLabel; + } + + const valueText = `${rawValue}`.trim(); + + if (!valueText) { + return missingReportInfoLabel; + } + + if (/^\d+(?:\.\d+)?$/u.test(valueText)) { + return `${Math.max(0, Math.round(Number(valueText))).toLocaleString('ko-KR')}일`; + } + + return valueText; +} + +function resolveDomainAgeText(sources: unknown[]): string { + const stringValue = pickSourceString(sources, ['domainAge', 'domain_age']); + + if (stringValue) { + return formatDomainAgeText(stringValue); + } + + return formatDomainAgeText(pickSourceNumber(sources, ['domainAge', 'domain_age'])); +} + +export function buildReportReputation( + reputationRecord: Record | null, + internalDbRecord: Record | null, + sources: unknown[], +): ReportPageData['reputation'] { + const reputationSummaryRecord = + pickRecord(reputationRecord, ['summary']) ?? asRecord(reputationRecord); + const metricSources = [reputationSummaryRecord, reputationRecord, internalDbRecord, ...sources]; + + return { + detailDescription: + pickSourceString([reputationRecord], ['detailDescription', 'detail_description']) ?? + '평판 상세 설명이 제공되지 않았습니다.', + providerName: + pickSourceString([reputationRecord], ['providerName', 'provider_name', 'provider']) ?? + 'Google Safe Browsing', + providerStatusText: + pickSourceString([reputationRecord], ['providerStatusText', 'provider_status_text']) ?? + resolveExternalApiStatus(reputationRecord) ?? + '검사 완료', + summary: { + domainAgeText: resolveDomainAgeText(metricSources), + reportCount: resolveSummaryCount(metricSources, [ + 'reportCount', + 'report_count', + 'phishingCount', + 'phishing_count', + ]), + }, + }; +} diff --git a/src/pages/Report/lib/toReportPageData.ts b/src/pages/Report/lib/toReportPageData.ts index b547fcc..9e31263 100644 --- a/src/pages/Report/lib/toReportPageData.ts +++ b/src/pages/Report/lib/toReportPageData.ts @@ -1,20 +1,16 @@ import { - asRecord, pickBoolean, - pickRecord, pickSourceNumber, pickSourceRecord, pickSourceString, pickSourceStringArray, } from '@/shared/api/responseAccess/payloadAccess'; -import { resolveResultToneFromSources } from '@/shared/api/risk/resolveResultTone'; -import { - resolveScanUrls, - type ResolvedScanUrls, -} from '@/shared/lib/scan-session/scanUrlResolution'; +import type { ResolvedScanUrls } from '@/shared/lib/scan-session/scanUrlResolution'; import type { ScanSessionSnapshot } from '@/shared/store/scanSessionStore'; import type { ResultTone } from '@/shared/types/resultTone'; +import { createReportPageDataContext } from './reportPageDataContext'; +import { buildReportReputation } from './reportReputationData'; import { missingReportInfoLabel, reportFallbackCopyByTone, @@ -23,10 +19,6 @@ import { import type { ReportPageData } from '../types/reportPage.types'; -function clampCount(value: number | null): number { - return value === null ? 0 : Math.max(0, Math.round(value)); -} - function clampTrustScore(value: number | null, riskLevel: ResultTone): number { if (value === null) { return trustScoreFallbackByTone[riskLevel]; @@ -35,15 +27,6 @@ function clampTrustScore(value: number | null, riskLevel: ResultTone): number { return Math.max(0, Math.min(100, Math.round(value))); } -function getSessionSources(session: ScanSessionSnapshot): unknown[] { - return [ - session.analysisDetail, - session.finalResult, - session.scanResponse, - session.historySelection, - ]; -} - function formatDateLabel(rawDate: string | null, fallbackLabel = '분석 시각 정보 없음'): string { if (!rawDate) { return fallbackLabel; @@ -128,50 +111,6 @@ function resolveUrlComparisonSummary({ destinationUrl, originalUrl }: ResolvedSc return '스캔된 QR URL과 최종 목적지가 동일합니다.'; } -function resolveExternalApiStatus( - externalApiRecord: Record | null, -): string | null { - const result = pickSourceString([externalApiRecord], ['result', 'status']); - - if (!result) { - return null; - } - - return `검사 결과: ${result}`; -} - -function resolveSummaryCount(sources: unknown[], keys: string[]): number { - return clampCount(pickSourceNumber(sources, keys)); -} - -function formatDomainAgeText(rawValue: string | number | null): string { - if (rawValue === null) { - return missingReportInfoLabel; - } - - const valueText = `${rawValue}`.trim(); - - if (!valueText) { - return missingReportInfoLabel; - } - - if (/^\d+(?:\.\d+)?$/u.test(valueText)) { - return `${Math.max(0, Math.round(Number(valueText))).toLocaleString('ko-KR')}일`; - } - - return valueText; -} - -function resolveDomainAgeText(sources: unknown[]): string { - const stringValue = pickSourceString(sources, ['domainAge', 'domain_age']); - - if (stringValue) { - return formatDomainAgeText(stringValue); - } - - return formatDomainAgeText(pickSourceNumber(sources, ['domainAge', 'domain_age'])); -} - function resolveServerInfoRecord(rawServerInfoRecord: Record | null) { if (!rawServerInfoRecord) { return { @@ -226,38 +165,6 @@ function buildDomainComparison( }; } -function buildReputation( - reputationRecord: Record | null, - internalDbRecord: Record | null, - sources: unknown[], -): ReportPageData['reputation'] { - const reputationSummaryRecord = - pickRecord(reputationRecord, ['summary']) ?? asRecord(reputationRecord); - const metricSources = [reputationSummaryRecord, reputationRecord, internalDbRecord, ...sources]; - - return { - detailDescription: - pickSourceString([reputationRecord], ['detailDescription', 'detail_description']) ?? - '평판 상세 설명이 제공되지 않았습니다.', - providerName: - pickSourceString([reputationRecord], ['providerName', 'provider_name', 'provider']) ?? - 'Google Safe Browsing', - providerStatusText: - pickSourceString([reputationRecord], ['providerStatusText', 'provider_status_text']) ?? - resolveExternalApiStatus(reputationRecord) ?? - '검사 완료', - summary: { - domainAgeText: resolveDomainAgeText(metricSources), - reportCount: resolveSummaryCount(metricSources, [ - 'reportCount', - 'report_count', - 'phishingCount', - 'phishing_count', - ]), - }, - }; -} - function buildServerInfo( serverInfoRecord: Record | null, certificateRecord: Record | null, @@ -288,30 +195,21 @@ function buildServerInfo( } export function toReportPageData(session: ScanSessionSnapshot): ReportPageData { - const sources = getSessionSources(session); - const riskLevel = resolveResultToneFromSources(sources, session.riskLevel) ?? 'warning'; - const urls = resolveScanUrls({ - decodedUrl: session.decodedUrl, - historyScannedAt: session.historySelection?.scannedAt, - historyUrl: session.historySelection?.url, + const { + domainComparisonRecord, + internalDbRecord, + reputationRecord, + riskLevel, + serverInfoRecord: rawServerInfoRecord, sources, - }); - const reputationRecord = pickSourceRecord(sources, [ - 'reputation', - 'reputationSummary', - 'externalApi', - 'external_api', - ]); - const domainComparisonRecord = pickSourceRecord(sources, ['domainComparison', 'domain_compare']); - const internalDbRecord = pickSourceRecord(sources, ['internalDb', 'internal_db']); - const { certificateRecord, serverInfoRecord } = resolveServerInfoRecord( - pickSourceRecord(sources, ['serverInfo', 'server_info']), - ); + urls, + } = createReportPageDataContext(session); + const { certificateRecord, serverInfoRecord } = resolveServerInfoRecord(rawServerInfoRecord); return { detectedRiskTypes: resolveDetectedRiskTypes(sources, riskLevel), domainComparison: buildDomainComparison(domainComparisonRecord, riskLevel, urls), - reputation: buildReputation(reputationRecord, internalDbRecord, sources), + reputation: buildReportReputation(reputationRecord, internalDbRecord, sources), reportTitle: '상세 분석 리포트', riskDescription: reportFallbackCopyByTone[riskLevel].riskDescription, riskLevel, From f68513662e4a5ae319768f6457bc02f9b625b12a Mon Sep 17 00:00:00 2001 From: kimsubsub Date: Thu, 14 May 2026 22:28:26 +0900 Subject: [PATCH 24/27] =?UTF-8?q?docs:=20=EB=A6=AC=ED=8F=AC=ED=8A=B8=20?= =?UTF-8?q?=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EB=B6=84=EB=A6=AC=20=EA=B8=B0?= =?UTF-8?q?=EB=A1=9D=20=EA=B0=B1=EC=8B=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/refactor-roadmap.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/refactor-roadmap.md b/docs/refactor-roadmap.md index 3882261..1e99e59 100644 --- a/docs/refactor-roadmap.md +++ b/docs/refactor-roadmap.md @@ -18,6 +18,7 @@ - 2026-05-14: P1 상태 관리 1차 보강 완료. `scanSessionStore`의 상태 전환 계산을 순수 함수로 분리하고 회귀 테스트를 추가했다. - 2026-05-14: P2 로딩/SSE 1차 분리 완료. 결과 라우트 결정, 스캔 식별자 비교, 완료 진행 이벤트 판정을 순수 함수로 분리했다. - 2026-05-14: P2 로딩 상세 조회 1차 분리 완료. 상세 조회 재시도와 polling 타이머 정책을 테스트 가능한 유틸로 분리했다. +- 2026-05-14: P2 리포트 데이터 1차 분리 완료. 리포트 컨텍스트 추출과 평판 섹션 빌더를 분리하고 회귀 테스트를 추가했다. ## 관찰 결과 From 20d94e7b073538559797984da7ba5c20a6ed1e8d Mon Sep 17 00:00:00 2001 From: kimsubsub Date: Thu, 14 May 2026 22:37:39 +0900 Subject: [PATCH 25/27] =?UTF-8?q?refactor:=20=EB=A6=AC=ED=8F=AC=ED=8A=B8?= =?UTF-8?q?=20=EB=8F=84=EB=A9=94=EC=9D=B8=20=EC=84=9C=EB=B2=84=20=EB=B9=8C?= =?UTF-8?q?=EB=8D=94=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/Report/lib/reportDomainData.test.ts | 43 +++++ src/pages/Report/lib/reportDomainData.ts | 36 ++++ .../Report/lib/reportServerInfoData.test.ts | 56 ++++++ src/pages/Report/lib/reportServerInfoData.ts | 123 ++++++++++++++ src/pages/Report/lib/toReportPageData.ts | 160 +----------------- 5 files changed, 266 insertions(+), 152 deletions(-) create mode 100644 src/pages/Report/lib/reportDomainData.test.ts create mode 100644 src/pages/Report/lib/reportDomainData.ts create mode 100644 src/pages/Report/lib/reportServerInfoData.test.ts create mode 100644 src/pages/Report/lib/reportServerInfoData.ts diff --git a/src/pages/Report/lib/reportDomainData.test.ts b/src/pages/Report/lib/reportDomainData.test.ts new file mode 100644 index 0000000..68ef71f --- /dev/null +++ b/src/pages/Report/lib/reportDomainData.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest'; + +import type { ResolvedScanUrls } from '@/shared/lib/scan-session/scanUrlResolution'; + +import { buildReportDomainComparison } from './reportDomainData'; + +const urls: ResolvedScanUrls = { + destinationUrl: 'https://final.example/path', + originalUrl: 'https://short.example/a', + previewUrl: 'https://final.example/path', + scannedAt: null, + scannedUrl: 'https://short.example/a', +}; + +describe('reportDomainData', () => { + it('uses backend domain comparison fields when present', () => { + expect( + buildReportDomainComparison( + { + officialUrl: 'https://official.example', + riskBadgeText: 'high risk', + summary: 'similar to official domain', + suspiciousUrl: 'https://danger.example', + }, + 'critical', + urls, + ), + ).toEqual({ + officialUrl: 'https://official.example', + riskBadgeText: 'high risk', + summary: 'similar to official domain', + suspiciousUrl: 'https://danger.example', + }); + }); + + it('falls back to resolved URLs and redirect summary', () => { + expect(buildReportDomainComparison(null, 'warning', urls)).toMatchObject({ + officialUrl: 'https://final.example/path', + summary: '스캔된 QR URL이 최종 목적지와 다른 주소로 리다이렉트되었습니다.', + suspiciousUrl: 'https://short.example/a', + }); + }); +}); diff --git a/src/pages/Report/lib/reportDomainData.ts b/src/pages/Report/lib/reportDomainData.ts new file mode 100644 index 0000000..d56fbb0 --- /dev/null +++ b/src/pages/Report/lib/reportDomainData.ts @@ -0,0 +1,36 @@ +import { pickSourceString } from '@/shared/api/responseAccess/payloadAccess'; +import type { ResolvedScanUrls } from '@/shared/lib/scan-session/scanUrlResolution'; +import type { ResultTone } from '@/shared/types/resultTone'; + +import { reportFallbackCopyByTone } from '../constants/reportText'; + +import type { ReportPageData } from '../types/reportPage.types'; + +function resolveUrlComparisonSummary({ destinationUrl, originalUrl }: ResolvedScanUrls): string { + if (originalUrl && destinationUrl && originalUrl !== destinationUrl) { + return '스캔된 QR URL이 최종 목적지와 다른 주소로 리다이렉트되었습니다.'; + } + + return '스캔된 QR URL과 최종 목적지가 동일합니다.'; +} + +export function buildReportDomainComparison( + domainComparisonRecord: Record | null, + riskLevel: ResultTone, + urls: ResolvedScanUrls, +): ReportPageData['domainComparison'] { + return { + officialUrl: + pickSourceString([domainComparisonRecord], ['officialUrl', 'official_url']) ?? + urls.destinationUrl, + riskBadgeText: + pickSourceString([domainComparisonRecord], ['riskBadgeText', 'risk_badge_text']) ?? + reportFallbackCopyByTone[riskLevel].domainRiskBadge, + summary: + pickSourceString([domainComparisonRecord], ['summary', 'description']) ?? + resolveUrlComparisonSummary(urls), + suspiciousUrl: + pickSourceString([domainComparisonRecord], ['suspiciousUrl', 'suspicious_url']) ?? + urls.originalUrl, + }; +} diff --git a/src/pages/Report/lib/reportServerInfoData.test.ts b/src/pages/Report/lib/reportServerInfoData.test.ts new file mode 100644 index 0000000..ac48c0e --- /dev/null +++ b/src/pages/Report/lib/reportServerInfoData.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'vitest'; + +import { buildReportServerInfo, resolveReportServerInfoRecords } from './reportServerInfoData'; +import { missingReportInfoLabel } from '../constants/reportText'; + +describe('reportServerInfoData', () => { + it('normalizes nested certificate fields into server info fields', () => { + const { certificateRecord, serverInfoRecord } = resolveReportServerInfoRecords({ + certificate: { + issuer: 'ACME CA', + valid: true, + validFrom: '2026-01-01', + validTo: '2027-01-01', + }, + location: 'Seoul, KR', + type: 'nginx', + }); + + expect(serverInfoRecord).toMatchObject({ + certificateStatusText: '유효한 인증서', + certificateValidityPeriod: '2026-01-01 - 2027-01-01', + serverLocation: 'Seoul, KR', + serverType: 'nginx', + }); + expect(buildReportServerInfo(serverInfoRecord, certificateRecord)).toMatchObject({ + certificateIssuer: 'ACME CA', + certificateStatusTone: 'success', + serverLocation: 'Seoul, KR', + serverType: 'nginx', + }); + }); + + it('marks invalid certificate status as error', () => { + const { certificateRecord, serverInfoRecord } = resolveReportServerInfoRecords({ + certificate: { + valid: false, + }, + }); + + expect(buildReportServerInfo(serverInfoRecord, certificateRecord)).toMatchObject({ + certificateStatusText: '유효하지 않은 인증서', + certificateStatusTone: 'error', + }); + }); + + it('fills missing server fields with the shared missing label', () => { + expect(buildReportServerInfo(null, null)).toMatchObject({ + certificateIssuer: missingReportInfoLabel, + certificateStatusText: '확인 필요', + certificateStatusTone: 'warning', + certificateValidityPeriod: missingReportInfoLabel, + serverLocation: missingReportInfoLabel, + serverType: missingReportInfoLabel, + }); + }); +}); diff --git a/src/pages/Report/lib/reportServerInfoData.ts b/src/pages/Report/lib/reportServerInfoData.ts new file mode 100644 index 0000000..a6e62b5 --- /dev/null +++ b/src/pages/Report/lib/reportServerInfoData.ts @@ -0,0 +1,123 @@ +import { + pickBoolean, + pickSourceRecord, + pickSourceString, +} from '@/shared/api/responseAccess/payloadAccess'; + +import { missingReportInfoLabel } from '../constants/reportText'; + +import type { ReportPageData } from '../types/reportPage.types'; + +type ReportServerInfoRecords = { + certificateRecord: Record | null; + serverInfoRecord: Record | null; +}; + +function formatCertificateStatus(certificateRecord: Record | null): string | null { + const isValid = pickBoolean(certificateRecord, ['valid', 'isValid', 'is_valid']); + + if (isValid === null) { + return null; + } + + return isValid ? '유효한 인증서' : '유효하지 않은 인증서'; +} + +function formatCertificateValidityPeriod( + certificateRecord: Record | null, +): string | null { + const validFrom = pickSourceString([certificateRecord], ['validFrom', 'valid_from']); + const validTo = pickSourceString([certificateRecord], ['validTo', 'valid_to']); + + if (!validFrom && !validTo) { + return null; + } + + return `${validFrom ?? '정보 없음'} - ${validTo ?? '정보 없음'}`; +} + +function resolveCertificateTone( + rawStatusText: string | null, +): ReportPageData['serverInfo']['certificateStatusTone'] { + if (!rawStatusText) { + return 'warning'; + } + + const normalizedStatusText = rawStatusText.trim().toLowerCase(); + const errorSignals = ['expired', 'invalid', 'revoked', '만료', '위조', '유효하지 않']; + const successSignals = ['valid', 'active', '유효', '정상']; + + if (errorSignals.some((signal) => normalizedStatusText.includes(signal))) { + return 'error'; + } + + if (successSignals.some((signal) => normalizedStatusText.includes(signal))) { + return 'success'; + } + + return 'warning'; +} + +export function resolveReportServerInfoRecords( + rawServerInfoRecord: Record | null, +): ReportServerInfoRecords { + if (!rawServerInfoRecord) { + return { + certificateRecord: null, + serverInfoRecord: null, + }; + } + + const certificateRecord = pickSourceRecord([rawServerInfoRecord], ['certificate']); + + return { + certificateRecord, + serverInfoRecord: { + ...rawServerInfoRecord, + certificateStatusText: + pickSourceString( + [rawServerInfoRecord], + ['certificateStatusText', 'certificate_status_text'], + ) ?? formatCertificateStatus(certificateRecord), + certificateValidityPeriod: + pickSourceString( + [rawServerInfoRecord], + ['certificateValidityPeriod', 'certificate_validity_period'], + ) ?? formatCertificateValidityPeriod(certificateRecord), + serverLocation: pickSourceString( + [rawServerInfoRecord], + ['serverLocation', 'server_location', 'location'], + ), + serverType: pickSourceString([rawServerInfoRecord], ['serverType', 'server_type', 'type']), + }, + }; +} + +export function buildReportServerInfo( + serverInfoRecord: Record | null, + certificateRecord: Record | null, +): ReportPageData['serverInfo'] { + const certificateStatusText = + pickSourceString([serverInfoRecord], ['certificateStatusText', 'certificate_status_text']) ?? + formatCertificateStatus(certificateRecord) ?? + '확인 필요'; + + return { + certificateIssuer: + pickSourceString([serverInfoRecord], ['certificateIssuer', 'certificate_issuer']) ?? + pickSourceString([certificateRecord], ['issuer']) ?? + missingReportInfoLabel, + certificateStatusText, + certificateStatusTone: resolveCertificateTone(certificateStatusText), + certificateValidityPeriod: + pickSourceString( + [serverInfoRecord], + ['certificateValidityPeriod', 'certificate_validity_period'], + ) ?? missingReportInfoLabel, + serverLocation: + pickSourceString([serverInfoRecord], ['serverLocation', 'server_location']) ?? + missingReportInfoLabel, + serverType: + pickSourceString([serverInfoRecord], ['serverType', 'server_type']) ?? missingReportInfoLabel, + }; +} diff --git a/src/pages/Report/lib/toReportPageData.ts b/src/pages/Report/lib/toReportPageData.ts index 9e31263..0d843ec 100644 --- a/src/pages/Report/lib/toReportPageData.ts +++ b/src/pages/Report/lib/toReportPageData.ts @@ -1,21 +1,12 @@ -import { - pickBoolean, - pickSourceNumber, - pickSourceRecord, - pickSourceString, - pickSourceStringArray, -} from '@/shared/api/responseAccess/payloadAccess'; -import type { ResolvedScanUrls } from '@/shared/lib/scan-session/scanUrlResolution'; +import { pickSourceNumber, pickSourceStringArray } from '@/shared/api/responseAccess/payloadAccess'; import type { ScanSessionSnapshot } from '@/shared/store/scanSessionStore'; import type { ResultTone } from '@/shared/types/resultTone'; +import { buildReportDomainComparison } from './reportDomainData'; import { createReportPageDataContext } from './reportPageDataContext'; import { buildReportReputation } from './reportReputationData'; -import { - missingReportInfoLabel, - reportFallbackCopyByTone, - trustScoreFallbackByTone, -} from '../constants/reportText'; +import { buildReportServerInfo, resolveReportServerInfoRecords } from './reportServerInfoData'; +import { reportFallbackCopyByTone, trustScoreFallbackByTone } from '../constants/reportText'; import type { ReportPageData } from '../types/reportPage.types'; @@ -48,51 +39,6 @@ function formatDateLabel(rawDate: string | null, fallbackLabel = '분석 시각 return `${year}-${month}-${day} ${hours}:${minutes}`; } -function formatCertificateStatus(certificateRecord: Record | null): string | null { - const isValid = pickBoolean(certificateRecord, ['valid', 'isValid', 'is_valid']); - - if (isValid === null) { - return null; - } - - return isValid ? '유효한 인증서' : '유효하지 않은 인증서'; -} - -function formatCertificateValidityPeriod( - certificateRecord: Record | null, -): string | null { - const validFrom = pickSourceString([certificateRecord], ['validFrom', 'valid_from']); - const validTo = pickSourceString([certificateRecord], ['validTo', 'valid_to']); - - if (!validFrom && !validTo) { - return null; - } - - return `${validFrom ?? '알 수 없음'} - ${validTo ?? '알 수 없음'}`; -} - -function resolveCertificateTone( - rawStatusText: string | null, -): ReportPageData['serverInfo']['certificateStatusTone'] { - if (!rawStatusText) { - return 'warning'; - } - - const normalizedStatusText = rawStatusText.trim().toLowerCase(); - const errorSignals = ['expired', 'invalid', 'revoked', '만료', '폐기', '유효하지 않']; - const successSignals = ['valid', 'active', '유효', '정상']; - - if (errorSignals.some((signal) => normalizedStatusText.includes(signal))) { - return 'error'; - } - - if (successSignals.some((signal) => normalizedStatusText.includes(signal))) { - return 'success'; - } - - return 'warning'; -} - function resolveDetectedRiskTypes(sources: unknown[], riskLevel: ResultTone): string[] { const riskTypes = pickSourceStringArray( sources, @@ -103,97 +49,6 @@ function resolveDetectedRiskTypes(sources: unknown[], riskLevel: ResultTone): st return riskTypes.length > 0 ? riskTypes : reportFallbackCopyByTone[riskLevel].detectedRiskTypes; } -function resolveUrlComparisonSummary({ destinationUrl, originalUrl }: ResolvedScanUrls): string { - if (originalUrl && destinationUrl && originalUrl !== destinationUrl) { - return '스캔된 QR URL이 최종 목적지와 다른 주소로 리다이렉트됩니다.'; - } - - return '스캔된 QR URL과 최종 목적지가 동일합니다.'; -} - -function resolveServerInfoRecord(rawServerInfoRecord: Record | null) { - if (!rawServerInfoRecord) { - return { - certificateRecord: null, - serverInfoRecord: null, - }; - } - - const certificateRecord = pickSourceRecord([rawServerInfoRecord], ['certificate']); - - return { - certificateRecord, - serverInfoRecord: { - ...rawServerInfoRecord, - certificateStatusText: - pickSourceString( - [rawServerInfoRecord], - ['certificateStatusText', 'certificate_status_text'], - ) ?? formatCertificateStatus(certificateRecord), - certificateValidityPeriod: - pickSourceString( - [rawServerInfoRecord], - ['certificateValidityPeriod', 'certificate_validity_period'], - ) ?? formatCertificateValidityPeriod(certificateRecord), - serverLocation: pickSourceString( - [rawServerInfoRecord], - ['serverLocation', 'server_location', 'location'], - ), - serverType: pickSourceString([rawServerInfoRecord], ['serverType', 'server_type', 'type']), - }, - }; -} - -function buildDomainComparison( - domainComparisonRecord: Record | null, - riskLevel: ResultTone, - urls: ResolvedScanUrls, -): ReportPageData['domainComparison'] { - return { - officialUrl: - pickSourceString([domainComparisonRecord], ['officialUrl', 'official_url']) ?? - urls.destinationUrl, - riskBadgeText: - pickSourceString([domainComparisonRecord], ['riskBadgeText', 'risk_badge_text']) ?? - reportFallbackCopyByTone[riskLevel].domainRiskBadge, - summary: - pickSourceString([domainComparisonRecord], ['summary', 'description']) ?? - resolveUrlComparisonSummary(urls), - suspiciousUrl: - pickSourceString([domainComparisonRecord], ['suspiciousUrl', 'suspicious_url']) ?? - urls.originalUrl, - }; -} - -function buildServerInfo( - serverInfoRecord: Record | null, - certificateRecord: Record | null, -): ReportPageData['serverInfo'] { - const certificateStatusText = - pickSourceString([serverInfoRecord], ['certificateStatusText', 'certificate_status_text']) ?? - formatCertificateStatus(certificateRecord) ?? - '확인 필요'; - - return { - certificateIssuer: - pickSourceString([serverInfoRecord], ['certificateIssuer', 'certificate_issuer']) ?? - pickSourceString([certificateRecord], ['issuer']) ?? - missingReportInfoLabel, - certificateStatusText, - certificateStatusTone: resolveCertificateTone(certificateStatusText), - certificateValidityPeriod: - pickSourceString( - [serverInfoRecord], - ['certificateValidityPeriod', 'certificate_validity_period'], - ) ?? missingReportInfoLabel, - serverLocation: - pickSourceString([serverInfoRecord], ['serverLocation', 'server_location']) ?? - missingReportInfoLabel, - serverType: - pickSourceString([serverInfoRecord], ['serverType', 'server_type']) ?? missingReportInfoLabel, - }; -} - export function toReportPageData(session: ScanSessionSnapshot): ReportPageData { const { domainComparisonRecord, @@ -204,11 +59,12 @@ export function toReportPageData(session: ScanSessionSnapshot): ReportPageData { sources, urls, } = createReportPageDataContext(session); - const { certificateRecord, serverInfoRecord } = resolveServerInfoRecord(rawServerInfoRecord); + const { certificateRecord, serverInfoRecord } = + resolveReportServerInfoRecords(rawServerInfoRecord); return { detectedRiskTypes: resolveDetectedRiskTypes(sources, riskLevel), - domainComparison: buildDomainComparison(domainComparisonRecord, riskLevel, urls), + domainComparison: buildReportDomainComparison(domainComparisonRecord, riskLevel, urls), reputation: buildReportReputation(reputationRecord, internalDbRecord, sources), reportTitle: '상세 분석 리포트', riskDescription: reportFallbackCopyByTone[riskLevel].riskDescription, @@ -216,7 +72,7 @@ export function toReportPageData(session: ScanSessionSnapshot): ReportPageData { riskLevelText: reportFallbackCopyByTone[riskLevel].riskLevelText, scannedAt: formatDateLabel(urls.scannedAt), scannedUrl: urls.scannedUrl, - serverInfo: buildServerInfo(serverInfoRecord, certificateRecord), + serverInfo: buildReportServerInfo(serverInfoRecord, certificateRecord), trustScore: clampTrustScore( pickSourceNumber(sources, ['trustScore', 'trust_score', 'score']), riskLevel, From 4ebd620bd96c28fb519b4fa3464da7f33c3fff4c Mon Sep 17 00:00:00 2001 From: kimsubsub Date: Thu, 14 May 2026 22:38:09 +0900 Subject: [PATCH 26/27] =?UTF-8?q?docs:=20=EB=A6=AC=ED=8C=A9=ED=86=A0?= =?UTF-8?q?=EB=A7=81=20=EC=99=84=EB=A3=8C=20=EB=AC=B8=EC=84=9C=20=EC=B5=9C?= =?UTF-8?q?=EC=8B=A0=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 134 +++++++++++++++++++++++++++++++------- docs/convention.md | 137 ++++++++++++++++++++++++--------------- docs/refactor-roadmap.md | 106 ++++++++++++++++-------------- 3 files changed, 253 insertions(+), 124 deletions(-) diff --git a/README.md b/README.md index f274565..c989780 100644 --- a/README.md +++ b/README.md @@ -1,78 +1,162 @@ -# Veri-Q +# Veri-Q Client -의심스러운 QR 코드와 링크를 빠르게 점검하고, 결과에 따라 대응 가이드를 제공하는 퀴싱 방지 웹사이트입니다. +Veri-Q는 QR 코드 이미지에서 URL 또는 비 URL 스킴을 분석하고, 위험도에 맞는 결과 페이지와 상세 리포트를 제공하는 React 클라이언트입니다. ## Tech Stack -| 분야 | 기술 | -| ------------- | ---------------------------------- | -| Language | TypeScript | -| Frontend | React 19 | -| Build Tool | Vite | -| Routing | TanStack Router | -| Styling | vanilla-extract (theme tokens) | -| Quality | ESLint, Prettier, Vitest | -| CI / Security | GitHub Actions, CodeQL, Secretlint | +| Area | Stack | +| -------- | ------------------------------------ | +| Language | TypeScript | +| Frontend | React 19 | +| Build | Vite | +| Routing | TanStack Router | +| State | Zustand | +| Styling | vanilla-extract | +| UI | Ant Design | +| Quality | ESLint, Prettier, Vitest, TypeScript | +| Security | Secretlint, pnpm audit, CodeQL | -## Project Structure +## Architecture + +현재 구조는 FSD를 가볍게 적용한 FSD-lite 방식입니다. ```text src/ - pages/ # page-level ui - routes/ # tanstack router setup - features/ # user action features - shared/ # shared ui, libs, types, constants + pages/ # 페이지 조합, page-local hooks/lib/ui + routes/ # TanStack Router 설정과 route-level lazy loading + features/ # 사용자 액션 중심 기능 API와 타입 + shared/ # 공통 API, store, lib, UI, icon, type + test-code/ # 테스트용 QR PNG 생성 스크립트 ``` -## Getting Started +주요 정리 내용: + +- `shared/lib/scan-session`: URL/스키마/위험도/결과 라우트/스캔 식별자 공통화 +- `shared/store`: 스캔 세션 상태 전환을 순수 함수로 분리 +- `pages/Loading/lib`: SSE 완료 판정, 상세 조회 retry, polling 정책 분리 +- `pages/Report/lib`: 리포트 컨텍스트, 평판, 도메인, 서버 정보 섹션 빌더 분리 +- `routes/router.tsx`: 페이지 단위 `lazy()`와 `Suspense` 적용 +- `vite.config.ts`: `react`, `antd`, `router`, `state` vendor chunk 분리 + +## Setup ```bash pnpm install +``` + +`postinstall`이 없으면 `.env.local`과 `.env.server.local`을 예시 파일에서 생성합니다. + +필요한 환경 파일: + +- `.env.local`: 브라우저에서 사용하는 Vite 환경 변수 +- `.env.server.local`: 로컬 captcha verify 서버의 서버 전용 secret + +예시 파일: + +- [.env.example](./.env.example) +- [.env.server.example](./.env.server.example) + +## Development + +```bash pnpm dev ``` -기본 개발 서버 주소: +기본 주소: ```text http://localhost:5173 ``` +로컬 개발 서버는 Vite 앱과 captcha verify 서버를 함께 실행합니다. + +프론트만 실행하려면: + +```bash +pnpm dev:web +``` + +## Backend Proxy + +로컬 Vite proxy와 Vercel rewrite는 다음 경로를 사용합니다. + +| Path | Target | +| -------- | ------------- | +| `/be1/*` | BE1 API | +| `/be3/*` | BE3 API / SSE | + +주요 환경 변수: + +| Variable | Description | +| ------------------------ | ---------------------------------------- | +| `VITE_BE1_BASE_URL` | 브라우저 기준 BE1 base URL 또는 `/be1` | +| `VITE_BE3_BASE_URL` | 브라우저 기준 BE3 base URL 또는 `/be3` | +| `BE1_PROXY_TARGET_URL` | Vercel Function에서 사용할 BE1 실제 대상 | +| `BE3_PROXY_TARGET_URL` | Vercel Function에서 사용할 BE3 실제 대상 | +| `VITE_API_TIMEOUT_MS` | 일반 API timeout | +| `VITE_UPLOAD_TIMEOUT_MS` | QR 이미지 업로드 timeout | +| `VITE_SSE_RECONNECT_MAX` | SSE 최대 재연결 횟수 | + ## Scripts ```bash pnpm dev +pnpm dev:web pnpm build -pnpm start +pnpm preview pnpm lint pnpm lint:fix pnpm format pnpm format:write pnpm test -pnpm test-code +pnpm typecheck +pnpm security:check +``` + +CI 기준으로 로컬에서 확인할 때는 아래 명령을 모두 통과시키면 됩니다. + +```bash +pnpm format +pnpm lint +pnpm test +pnpm typecheck pnpm security:check +pnpm build ``` ## Test QR Code -테스트할 URL을 QR 이미지로 만들 때 사용합니다. +테스트 URL을 QR PNG로 만들 수 있습니다. ```bash pnpm test-code https://naver.com ``` -`//`가 빠진 값도 자동 보정됩니다. +`//`가 빠진 입력도 보정합니다. ```bash pnpm test-code: https:naver.com ``` -생성된 PNG는 `src/test-code/generated-qr/`에 저장됩니다. 쿼리스트링에 `&`가 들어간 URL은 PowerShell에서 분리될 수 있으니 따옴표로 감싸서 실행합니다. +생성 위치: + +```text +src/test-code/generated-qr/ +``` + +PowerShell에서 쿼리스트링에 `&`가 있는 URL은 따옴표로 감싸세요. ```bash pnpm test-code "https://example.com/?a=1&b=2" ``` +## Commit Safety + +- `.env.local`과 `.env.server.local`은 커밋하지 않습니다. +- pre-commit hook에서 secretlint, eslint, prettier가 staged file 기준으로 실행됩니다. +- 보안 확인은 `pnpm security:check`로 수행합니다. + ## Documents -- [협업 가이드](./CONTRIBUTING.md) -- [코드 컨벤션](./docs/convention.md) +- [작업 컨벤션](./docs/convention.md) +- [리팩토링 로드맵](./docs/refactor-roadmap.md) diff --git a/docs/convention.md b/docs/convention.md index 17a3454..0edb3ba 100644 --- a/docs/convention.md +++ b/docs/convention.md @@ -1,35 +1,36 @@ # Convention -> 이 문서는 프로젝트의 기본 코드 컨벤션을 정리한 문서입니다. 설명보다 규칙과 예시를 우선합니다. +이 문서는 Veri-Q Client의 기본 코드 작성 규칙입니다. 설명보다 일관성을 우선하고, 기존 구조와 가까운 방식으로 변경합니다. -## 기본 원칙 +## Architecture -- 일관성을 우선합니다. -- 한 파일은 한 가지 책임만 갖도록 작성합니다. -- 페이지는 조합에 집중하고, 사용자 액션 로직은 `features`에 둡니다. -- 불필요한 레이어를 만들지 않습니다. 필요할 때만 추가합니다. - -## 폴더 구조 +현재 프로젝트는 FSD-lite 구조를 사용합니다. ```text src/ - pages/ # 페이지 UI 컴포넌트 - routes/ # TanStack Router 설정 및 라우트 연결 + pages/ # 페이지 조합, page-local hooks/lib/ui + routes/ # TanStack Router 설정 features/ # 사용자 액션 단위 기능 - shared/ # 공통 UI, 유틸, 타입, 상수, API - widgets/ # (선택) 여러 요소를 조합한 큰 UI 블록 + shared/ # 공통 API, store, lib, UI, icon, type + test-code/ # 테스트 보조 스크립트 ``` -## 컴포넌트 선언 및 export +원칙: + +- `pages`는 화면 조합과 페이지 전용 로직을 둔다. +- `features`는 업로드, 이력 조회처럼 사용자 액션 중심 기능을 둔다. +- `shared`는 여러 영역에서 재사용하는 API, store, lib, type, UI만 둔다. +- `widgets`는 아직 사용하지 않는다. 여러 페이지에서 재사용되는 조합 UI가 생길 때 추가한다. +- 새 레이어는 필요가 명확할 때만 만든다. -- 페이지/컴포넌트 파일은 `default export`를 사용합니다. -- 유틸/훅/상수는 `named export`를 사용합니다. -- 익명 `default export`는 사용하지 않습니다. +## Exports -짧은 예시: +- 페이지 컴포넌트는 `default export`를 사용한다. +- 유틸, 상수, 타입, store는 `named export`를 사용한다. +- 익명 `default export`는 사용하지 않는다. ```tsx -export default function HomePage() { +export default function ReportPage() { return
; } ``` @@ -38,58 +39,90 @@ export default function HomePage() { export const MAX_URL_LENGTH = 2048; ``` -## import 순서 +## Import Order -1. 프레임워크/런타임 모듈 +ESLint `import/order` 규칙을 따른다. + +1. Node/builtin 2. 외부 패키지 -3. 프로젝트 내부 절대 경로 -4. 현재 디렉터리 기준 상대 경로 +3. 내부 alias 경로 +4. 상대 경로 +5. type import -프로젝트 내부 절대 경로 순서: +내부 alias 우선순위: -`shared -> features -> widgets -> pages -> routes -> local` +```text +shared -> features -> widgets -> pages -> routes +``` -추가 규칙: +규칙: -- 그룹 사이에는 한 줄 공백을 둡니다. -- 타입 전용 import는 `import type`으로 분리합니다. +- import 그룹 사이에는 빈 줄을 둔다. +- 같은 그룹 안에는 불필요한 빈 줄을 두지 않는다. +- 타입 전용 import는 `import type`을 사용한다. -## 파일명 규칙 +## File Naming -| 구분 | 규칙 | 예시 | -| --------------------- | --------------------- | --------------------------------- | -| 폴더명 | `kebab-case` | `scan-url`, `result-summary` | -| 컴포넌트 파일 | `PascalCase` | `HomePage.tsx`, `UrlScanForm.tsx` | -| util 함수 / 상수 파일 | `lowerCamelCase` | `formatDate.ts`, `constants.ts` | -| 테스트 파일 | 대상 파일명 + `.test` | `isSafeExternalUrl.test.ts` | +| Target | Rule | Example | +| ----------------- | --------------------- | ---------------------------- | +| Folder | `kebab-case` | `scan-url`, `result-summary` | +| Page/component | `PascalCase` | `ReportPage.tsx` | +| Hook | `useSomething.ts` | `useLoadingPage.ts` | +| Utility/constants | `lowerCamelCase` | `scanResultRoute.ts` | +| Test | source name + `.test` | `scanResultRoute.test.ts` | -REST API 관련 파일명/함수명 접두사: +API 함수 접두어: - GET: `fetch` - POST: `submit` -- DELETE: `remove` - PUT/PATCH: `update` +- DELETE: `remove` -## 슬라이스 공개 범위 +## Page Logic -- 슬라이스 외부 공개 진입점은 가능하면 `index.ts`로 관리합니다. -- 다른 슬라이스의 내부 경로 직접 import는 지양합니다. +- 페이지 컴포넌트는 화면 조합에 집중한다. +- 복잡한 데이터 변환은 `lib`의 순수 함수로 분리한다. +- 페이지 전용 hook은 `hooks`에 둔다. +- 여러 페이지에서 쓰는 로직은 `shared/lib` 또는 `shared/store`로 올린다. +- 테스트 가능한 로직은 UI에서 빼서 먼저 테스트할 수 있게 만든다. -## 컴포넌트 작성 규칙 +## State -- 페이지 컴포넌트는 화면 조합과 데이터 연결에 집중합니다. -- `shared/ui`에는 도메인 의존성이 없는 공용 UI만 둡니다. -- 재사용되지 않는 컴포넌트는 해당 기능/페이지 근처에 둡니다. -- `utils.ts`, `helpers.ts`, `common.ts`처럼 의미가 약한 파일명은 지양합니다. +- Zustand store는 상태 보관과 액션 노출에 집중한다. +- 상태 전환 계산은 가능한 순수 함수로 분리한다. +- persist 대상은 필요한 최소 필드만 저장한다. + +## Security + +- 외부 링크와 실행 스킴은 허용 목록 기반으로 처리한다. +- 위험하거나 사용자 행동을 유발하는 스킴은 확인 단계를 둔다. +- `.env.local`, `.env.server.local`은 커밋하지 않는다. +- 새 API/보안 정책 변경은 성공 케이스와 차단 케이스를 모두 테스트한다. + +## Tests + +- 공통 유틸과 데이터 변환은 단위 테스트를 붙인다. +- 큰 리팩토링 전에는 기존 회귀 테스트를 보강한다. +- CI 기준 검증은 다음 명령이다. + +```bash +pnpm format +pnpm lint +pnpm test +pnpm typecheck +pnpm security:check +pnpm build +``` -## 접근성 +## Accessibility -- `header`, `nav`, `main`, `section`, `article`, `aside`, `footer` 같은 시맨틱 태그를 우선 사용합니다. -- 인터랙티브 요소에는 텍스트, `label`, `aria-label` 중 하나가 필요합니다. -- 아이콘만 있는 버튼은 반드시 `aria-label`을 추가합니다. +- `main`, `section`, `article`, `nav`, `header`, `footer` 같은 시맨틱 태그를 우선한다. +- 인터랙티브 요소에는 텍스트 label 또는 `aria-label`을 제공한다. +- 아이콘만 있는 버튼에는 반드시 `aria-label`을 둔다. -## Router Note +## Routing -- 라우트 정의는 `src/routes`에 둡니다. -- 화면 컴포넌트는 `src/pages`에서 import 합니다. -- 파일 기반 자동 생성 대신 명시적인 라우트 트리 구성을 기본으로 사용합니다. +- 라우트 정의는 `src/routes`에 둔다. +- 페이지 컴포넌트는 `src/pages`에서 import한다. +- 페이지는 route-level `lazy()`로 불러온다. +- 라우트 fallback은 접근 가능한 `role="status"` 영역으로 제공한다. diff --git a/docs/refactor-roadmap.md b/docs/refactor-roadmap.md index 1e99e59..56a56fe 100644 --- a/docs/refactor-roadmap.md +++ b/docs/refactor-roadmap.md @@ -1,60 +1,72 @@ # Refactor Roadmap -## 현재 기준선 +## Current Baseline -- 브랜치: `feat/42` -- 테스트: `pnpm test` 통과 -- 린트: `pnpm lint` 통과 -- 보안 점검: `pnpm security:check` 통과 -- 타입 체크: `pnpm typecheck` 통과 -- 빌드: `pnpm build` 통과 +- Branch: `feat/42` +- Test: `pnpm test` passed +- Lint: `pnpm lint` passed +- Format: `pnpm format` passed +- Typecheck: `pnpm typecheck` passed +- Security: `pnpm security:check` passed +- Build: `pnpm build` passed -## 진행 기록 +## Status + +All planned refactor items in this roadmap are complete as of 2026-05-14. + +## Progress Log - 2026-05-14: P0 CI 안정성 보강 완료. `typecheck` 스크립트와 CI 단계를 추가했다. -- 2026-05-14: P1 비 URL 실행 보안 1차 보강 완료. 실행 가능한 스킴을 제한하고, 실행 전 확인 단계를 추가했다. +- 2026-05-14: P1 비 URL 실행 보안 1차 보강 완료. 실행 가능한 스킴을 허용 목록으로 제한하고, 실행 전 확인 단계를 추가했다. - 2026-05-14: P1 응답 정규화 1차 보강 완료. URL, 스키마, 웹 여부, 위험도 판정을 공통 유틸로 분리했다. - 2026-05-14: P1 URL 해석 1차 보강 완료. 결과/리포트 페이지의 스캔 URL, 원본 URL, 최종 URL 선택 기준을 공통화했다. - 2026-05-14: P1 상태 관리 1차 보강 완료. `scanSessionStore`의 상태 전환 계산을 순수 함수로 분리하고 회귀 테스트를 추가했다. - 2026-05-14: P2 로딩/SSE 1차 분리 완료. 결과 라우트 결정, 스캔 식별자 비교, 완료 진행 이벤트 판정을 순수 함수로 분리했다. - 2026-05-14: P2 로딩 상세 조회 1차 분리 완료. 상세 조회 재시도와 polling 타이머 정책을 테스트 가능한 유틸로 분리했다. - 2026-05-14: P2 리포트 데이터 1차 분리 완료. 리포트 컨텍스트 추출과 평판 섹션 빌더를 분리하고 회귀 테스트를 추가했다. +- 2026-05-14: P2 리포트 데이터 2차 분리 완료. 도메인 비교와 서버/인증서 정보 빌더를 분리하고 회귀 테스트를 추가했다. +- 2026-05-14: P2 성능 점검 완료. 페이지 단위 lazy loading과 vendor chunk 분리 설정을 확인했다. +- 2026-05-14: P3 문서 정리 완료. README와 로드맵을 현재 구조, 스크립트, 검증 절차 기준으로 최신화했다. + +## Completed Items + +| Priority | Area | Result | +| -------- | ------------- | --------------------------------------------------------------------------------- | +| P0 | CI 안정성 | `typecheck` 스크립트와 CI typecheck 단계를 추가했다. | +| P1 | 보안 | 비 URL 실행 스킴을 허용 목록으로 제한하고 위험 스킴은 확인 단계를 거치게 했다. | +| P1 | 데이터 정규화 | URL, 위험도, 스키마, 웹 여부 판정을 공통 scan-session 유틸로 모았다. | +| P1 | 상태 관리 | `scanSessionStore`에서 상태 전환 계산을 순수 함수로 분리했다. | +| P2 | 로딩/SSE | SSE 최종 결과, 상세 조회 retry, polling, 결과 라우팅 판정을 작은 함수로 분리했다. | +| P2 | 성능 | route-level lazy loading과 vendor chunk 분리를 유지한다. | +| P2 | 리포트 페이지 | `toReportPageData`를 컨텍스트, 평판, 도메인, 서버 정보 빌더로 분리했다. | +| P3 | 문서 | README와 로드맵을 최신 구조와 검증 명령 기준으로 정리했다. | + +## Current Architecture Notes + +- `pages`는 화면 조합과 page-local hook/lib/ui를 맡는다. +- `features`는 사용자 액션 중심 API와 타입을 맡는다. +- `shared`는 공통 API, 상태 저장소, scan-session 유틸, 보안 유틸, 공용 UI를 맡는다. +- `routes`는 TanStack Router 설정과 route-level lazy loading을 맡는다. +- `widgets`는 아직 필요하지 않아 만들지 않았다. 여러 페이지에서 재사용되는 조합 UI가 생기면 추가한다. + +## Verification + +마지막 완료 기준 검증 명령: + +```bash +pnpm format +pnpm lint +pnpm test +pnpm typecheck +pnpm security:check +pnpm build +``` + +## Next Candidates + +현재 로드맵 범위는 완료했다. 이후 개선은 다음 별도 이슈로 분리하는 것이 좋다. -## 관찰 결과 - -- `vite build`만으로는 TypeScript 타입 오류가 잡히지 않는다. 실제로 `ResultNonUrlPage`의 존재하지 않는 필드 접근이 `tsc --noEmit`에서만 발견됐다. -- 백엔드 응답 정규화가 `scanSessionStore`, `toResultPageData`, `toReportPageData`, `historyItemAccess`, `toResultNonUrlPageData`에 나뉘어 있다. 위험도/URL/스키마 판정이 계속 불일치할 수 있는 구조다. -- 외부 HTTP 링크는 `openExternalLink`와 `isSafeExternalUrl`에서 제한하고 있지만, 비 URL 결과 실행은 `tel:`, `sms:`, `mailto:`, 앱 딥링크를 직접 실행한다. 허용 스킴과 사용자 확인 정책을 더 명확히 해야 한다. -- `scanSessionStore`가 URL, 위험도, 스키마, 분석 응답 정규화를 함께 담당한다. 장기적으로는 저장소는 상태 보관에 집중하고, 정규화는 별도 도메인 유틸로 분리하는 편이 안전하다. -- 긴 파일이 많다. 특히 `ReportPage`, `toReportPageData`, `useQRScanPage`, `useLoadingPage`, `resolveNonUrlActionExecution`은 변경 위험이 높고 테스트를 먼저 보강한 뒤 분리하는 것이 좋다. -- 빌드 결과에서 `antd`와 `react` 청크가 크다. 성능 개선은 먼저 라우트 단위 lazy loading과 실제 사용자 진입 경로 기준으로 접근하는 것이 좋다. -- 일부 문서/문구 파일은 인코딩이 깨져 보인다. 기능 리팩토링과 분리해서 문구/인코딩 정리 커밋으로 다루는 것이 안전하다. - -## 우선순위 - -| 우선순위 | 영역 | 목표 | 권장 커밋 단위 | -| -------- | ------------- | ------------------------------------------------------------------------- | ------------------------------------------------- | -| P0 | CI 안정성 | 타입 오류가 PR에서 반드시 잡히게 한다. | `typecheck` 스크립트와 CI 단계 추가 | -| P1 | 보안 | 비 URL 실행 스킴을 허용 목록으로 제한하고 위험 스킴은 확인 모달을 거친다. | 실행 정책 유틸 분리, 테스트 추가 | -| P1 | 데이터 정규화 | URL, 위험도, 스키마 판정을 하나의 공통 변환 계층으로 모은다. | `scan-result-normalizer` 유틸 추가, 페이지별 적용 | -| P1 | 상태 관리 | `scanSessionStore`에서 파싱 로직을 걷어내고 상태 저장 책임만 남긴다. | store 내부 정규화 함수 외부화 | -| P2 | 로딩/SSE | SSE 최종 결과, 상세 조회 폴링, 라우팅 전환을 더 작은 함수로 분리한다. | `useLoadingPage` 분리 및 테스트 보강 | -| P2 | 성능 | 라우트 단위 lazy loading과 무거운 청크 확인을 적용한다. | 라우터 lazy import, 빌드 크기 비교 | -| P2 | 리포트 페이지 | `ReportPage`와 `toReportPageData`를 섹션 단위로 쪼갠다. | URL/평판/서버정보 빌더 분리 | -| P3 | 문구/인코딩 | 깨진 문구와 문서 인코딩을 정리한다. | 문구 파일만 별도 수정 | - -## 추천 진행 순서 - -1. P0 CI 안정성 보강을 먼저 끝낸다. -2. 비 URL 실행 보안 정책을 정리한다. -3. 백엔드 응답 정규화 계층을 만들고 결과/리포트/이력에 순차 적용한다. -4. 상태 저장소에서 정규화 책임을 분리한다. -5. 라우트 lazy loading과 번들 크기 개선을 진행한다. -6. 리포트/QR 스캔/로딩 페이지의 큰 파일을 테스트와 함께 분리한다. - -## 작업 원칙 - -- 한 커밋은 한 책임만 가진다. -- 리팩토링 전에는 해당 영역 테스트를 먼저 추가하거나 기존 테스트를 보강한다. -- 보안 정책 변경은 성공 케이스와 차단 케이스를 모두 테스트한다. -- 성능 개선은 빌드 결과나 런타임 측정값을 남긴다. +- `ReportPage` JSX를 섹션 컴포넌트로 더 세분화 +- QR 스캔 카메라 제어 로직 분리 +- 실제 운영 번들 기준 시각화 도구 추가 +- UI 문구 전체 i18n 또는 copy catalog 도입 From 4b790301d9cf02872aa15b1ba760ecd812d0f1e3 Mon Sep 17 00:00:00 2001 From: kimsubsub Date: Thu, 14 May 2026 23:06:34 +0900 Subject: [PATCH 27/27] =?UTF-8?q?fix:=20=EB=A6=AC=EB=B7=B0=20=EC=BD=94?= =?UTF-8?q?=EB=A9=98=ED=8A=B8=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- src/pages/Loading/hooks/useLoadingPage.ts | 13 ++----------- .../Loading/lib/loadingDetailResolution.test.ts | 7 ++++--- src/pages/Loading/lib/loadingDetailResolution.ts | 6 ++++-- src/pages/Report/constants/threatText.ts | 2 +- src/shared/lib/scan-session/ensureScanDetail.ts | 4 +++- src/shared/lib/scan-session/scanSessionErrors.ts | 6 ++++++ .../lib/scan-session/scanUrlResolution.test.ts | 12 ++++++++++++ 8 files changed, 33 insertions(+), 19 deletions(-) create mode 100644 src/shared/lib/scan-session/scanSessionErrors.ts diff --git a/README.md b/README.md index c989780..535bfae 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,7 @@ src/ pnpm install ``` -`postinstall`이 없으면 `.env.local`과 `.env.server.local`을 예시 파일에서 생성합니다. +설치 중 `postinstall` 스크립트가 실행되며 예시 파일을 기준으로 `.env.local`과 `.env.server.local`을 생성합니다. 필요한 환경 파일: diff --git a/src/pages/Loading/hooks/useLoadingPage.ts b/src/pages/Loading/hooks/useLoadingPage.ts index 611d6fa..7deed90 100644 --- a/src/pages/Loading/hooks/useLoadingPage.ts +++ b/src/pages/Loading/hooks/useLoadingPage.ts @@ -30,6 +30,7 @@ const DEFAULT_LOADING_PAGE_DATA: LoadingPageData = { }; function openResultRouteForCurrentSession() { + // Keep a full reload here so lazy result pages initialize from the persisted scan session and URL query after SSE completion. window.location.assign(resolveScanResultRoute(getScanSessionSnapshot()).href); } @@ -157,22 +158,12 @@ export function useLoadingPage(): UseLoadingPageReturn { const hasResolvedRiskLevel = resolveResultToneFromSources([payload], null) !== null; if (!hasResolvedRiskLevel) { - let isResolved = false; - try { - isResolved = await resolveDetailAndOpenResult(); + await resolveDetailAndOpenResult(); } catch { return; } - if (isResolved) { - return; - } - - if (detailResolutionFailedRef.current) { - return; - } - return; } diff --git a/src/pages/Loading/lib/loadingDetailResolution.test.ts b/src/pages/Loading/lib/loadingDetailResolution.test.ts index dd287be..f2b5b63 100644 --- a/src/pages/Loading/lib/loadingDetailResolution.test.ts +++ b/src/pages/Loading/lib/loadingDetailResolution.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from 'vitest'; import { ApiError } from '@/shared/api/errors/apiError'; +import { ScanSessionRequiredError } from '@/shared/lib/scan-session/scanSessionErrors'; import { DETAIL_RESOLUTION_TIMEOUT_MESSAGE, @@ -59,7 +60,7 @@ describe('loadingDetailResolution', () => { await expect( resolveLoadingDetailWithRetry({ - ensureDetail: vi.fn().mockRejectedValue(new Error('SCAN_SESSION_REQUIRED')), + ensureDetail: vi.fn().mockRejectedValue(new ScanSessionRequiredError()), isActive: () => true, onFailure, onResolved: vi.fn(), @@ -70,7 +71,7 @@ describe('loadingDetailResolution', () => { expect(onFailure).not.toHaveBeenCalled(); }); - it('fails and rethrows non-404 errors', async () => { + it('fails and returns error for non-404 errors', async () => { const onFailure = vi.fn(); await expect( @@ -81,7 +82,7 @@ describe('loadingDetailResolution', () => { onResolved: vi.fn(), retryMax: 1, }), - ).rejects.toThrow('network failed'); + ).resolves.toBe('error'); expect(onFailure).toHaveBeenCalledWith('network failed'); }); diff --git a/src/pages/Loading/lib/loadingDetailResolution.ts b/src/pages/Loading/lib/loadingDetailResolution.ts index 0361fc5..58e32f5 100644 --- a/src/pages/Loading/lib/loadingDetailResolution.ts +++ b/src/pages/Loading/lib/loadingDetailResolution.ts @@ -1,4 +1,5 @@ import { isApiError } from '@/shared/api/errors/apiError'; +import { ScanSessionRequiredError } from '@/shared/lib/scan-session/scanSessionErrors'; export const DETAIL_RETRY_DELAY_MS = 2_000; export const DETAIL_RETRY_MAX = 60; @@ -8,6 +9,7 @@ export const DETAIL_RESOLUTION_TIMEOUT_MESSAGE = 'Analysis detail was not ready in time. Please retry.'; export type LoadingDetailResolutionStatus = + | 'error' | 'inactive' | 'resolved' | 'session-required' @@ -57,13 +59,13 @@ export async function resolveLoadingDetailWithRetry({ return 'inactive'; } - if (error instanceof Error && error.message === 'SCAN_SESSION_REQUIRED') { + if (error instanceof ScanSessionRequiredError) { return 'session-required'; } if (!isApiError(error) || error.statusCode !== 404) { onFailure(error instanceof Error ? error.message : DETAIL_RESOLUTION_ERROR_MESSAGE); - throw error; + return 'error'; } await delay(retryDelayMs); diff --git a/src/pages/Report/constants/threatText.ts b/src/pages/Report/constants/threatText.ts index 2101c44..de474cf 100644 --- a/src/pages/Report/constants/threatText.ts +++ b/src/pages/Report/constants/threatText.ts @@ -182,7 +182,7 @@ export const threatTextCatalog: RiskDetectionCatalogItem[] = [ description: 'bit.ly, tinyurl처럼 최종 목적지를 바로 확인하기 어려운 단축 URL 서비스가 사용되었습니다.', englishLabel: 'SHORTENED URL', - names: ['SHORTENED_URL', 'shortened_url', 'short_url', 'url_shortener'], + names: ['SHORTENED_URL'], risk: '실제 목적지를 숨긴 뒤 피싱 사이트나 악성 파일 배포지로 이동시키는 데 자주 사용됩니다.', title: '단축 URL 감지', }, diff --git a/src/shared/lib/scan-session/ensureScanDetail.ts b/src/shared/lib/scan-session/ensureScanDetail.ts index bee372f..0c89b84 100644 --- a/src/shared/lib/scan-session/ensureScanDetail.ts +++ b/src/shared/lib/scan-session/ensureScanDetail.ts @@ -1,6 +1,8 @@ import { fetchScanDetail } from '@/shared/api/fetchScanDetail'; import { getScanSessionSnapshot, useScanSessionStore } from '@/shared/store/scanSessionStore'; +import { ScanSessionRequiredError } from './scanSessionErrors'; + export function resolveRequestedUrlFromSearch(): string | null { if (typeof window === 'undefined') { return null; @@ -21,7 +23,7 @@ export async function ensureScanDetail(): Promise { scannedUrl: 'https://history.example/path', }); }); + + it('returns empty strings when all URL resolution attempts fail', () => { + expect( + resolveScanUrls({ + sources: [{ unrelatedField: 'value' }], + }), + ).toMatchObject({ + destinationUrl: '', + originalUrl: '', + scannedUrl: '', + }); + }); });