Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 9 additions & 4 deletions src/pages/Loading/hooks/useLoadingPage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ 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 { useScanSubscription } from '@/shared/lib/sse/useScanSubscription';
import { useGuestStore } from '@/shared/store/guestStore';
import { useScanProgressStore } from '@/shared/store/scanProgressStore';
Expand All @@ -32,10 +33,14 @@ const DEFAULT_LOADING_PAGE_DATA: LoadingPageData = {

function openResultRouteForCurrentSession() {
const session = getScanSessionSnapshot();
const currentTargetUrl = session.decodedUrl ?? session.historySelection?.url ?? null;

if (
session.isUrl === false ||
(session.schemeType && session.schemeType.trim().toUpperCase() !== 'WEB')
!isWebScanTarget({
isUrl: session.isUrl,
schemeType: session.schemeType,
url: currentTargetUrl,
})
) {
window.location.assign('/result/non-url');
return;
Expand All @@ -53,8 +58,8 @@ function openResultRouteForCurrentSession() {
} as const;
const route = routeByRiskLevel[riskLevel];

if (session.decodedUrl) {
window.location.assign(`${route}?url=${encodeURIComponent(session.decodedUrl)}`);
if (currentTargetUrl) {
window.location.assign(`${route}?url=${encodeURIComponent(currentTargetUrl)}`);
return;
}

Expand Down
26 changes: 19 additions & 7 deletions src/pages/QRScan/hooks/useQRScanPage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { App } from 'antd';
import { useCallback, useEffect, useRef, useState } from 'react';

import { showApiError } from '@/shared/lib/feedback/showApiError';
import { isWebScanTarget } from '@/shared/lib/scan-session/scanClassification';
import { useScanProgressStore } from '@/shared/store/scanProgressStore';
import { useScanSessionStore } from '@/shared/store/scanSessionStore';

Expand Down Expand Up @@ -141,18 +142,29 @@ function isNonWebScanResponse(scanResponse: Record<string, unknown>): boolean {
typeof scanResponse.schemeType === 'string'
? scanResponse.schemeType
: scanResponse.scheme_type;
const schemeType = typeof rawSchemeType === 'string' ? rawSchemeType.trim().toUpperCase() : '';
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;

if (typeof rawIsUrl === 'boolean') {
return rawIsUrl === false;
}

return schemeType.length > 0 && schemeType !== 'WEB';
return !isWebScanTarget({
isUrl: typeof rawIsUrl === 'boolean' ? rawIsUrl : null,
schemeType: typeof rawSchemeType === 'string' ? rawSchemeType : null,
url: targetValue,
});
}

function isWebHistoryItem(item: ScanHistoryItem): boolean {
return item.isUrl !== false && item.schemeType?.trim().toUpperCase() === 'WEB';
return isWebScanTarget(item);
}

function openResultPage(route: ResultRoute, url: string) {
Expand Down
22 changes: 5 additions & 17 deletions src/pages/ScanList/ScanListPage.tsx
Original file line number Diff line number Diff line change
@@ -1,22 +1,16 @@
import { Link } from '@tanstack/react-router';

import { qrIconByTone } from '@/shared/icon/resultIcons';
import { isWebScanTarget } from '@/shared/lib/scan-session/scanClassification';
import AppHeader from '@/shared/ui/app-header';

import { useScanListPage } from './hooks/useScanListPage';
import * as styles from './styles/scanListPage.css';

import type { ScanListStatus } from './types/scanListPage.types';

type ResultRoute = '/result/critical' | '/result/non-url' | '/result/safe' | '/result/warning';

const nonUrlResultRoute = '/result/non-url';

const resultRouteByStatus: Record<ScanListStatus, ResultRoute> = {
safe: '/result/safe',
warning: '/result/warning',
critical: '/result/critical',
};
const reportRoute = '/report';

const statusLabelByTone: Record<ScanListStatus, string> = {
safe: '안전',
Expand Down Expand Up @@ -91,10 +85,6 @@ function StatusBadgeIcon({ tone }: { tone: ScanListStatus }) {
);
}

function isWebScanListItem(item: { isUrl: boolean | null; schemeType: string | null }): boolean {
return item.isUrl !== false && item.schemeType?.trim().toUpperCase() === 'WEB';
}

export default function ScanListPage() {
const { handleSelectScanResult, scanListPageData, scanListUuid } = useScanListPage();

Expand All @@ -115,9 +105,7 @@ export default function ScanListPage() {
) : (
<ul className={styles.list}>
{scanListPageData.items.map((item) => {
const resultRoute = isWebScanListItem(item)
? resultRouteByStatus[item.status]
: nonUrlResultRoute;
const nextRoute = isWebScanTarget(item) ? reportRoute : nonUrlResultRoute;

return (
<li key={item.id}>
Expand All @@ -126,8 +114,8 @@ export default function ScanListPage() {
onClick={() => {
handleSelectScanResult(item);
}}
search={resultRoute === nonUrlResultRoute ? {} : { url: item.url }}
to={resultRoute}
search={nextRoute === reportRoute ? { url: item.url } : {}}
to={nextRoute}
>
<span className={`${styles.badge} ${styles.badgeTone[item.status]}`}>
<span aria-hidden="true" className={styles.badgeIcon}>
Expand Down
100 changes: 100 additions & 0 deletions src/shared/lib/scan-session/scanClassification.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { describe, expect, it } from 'vitest';

import { isHttpUrl, isWebScanTarget, normalizeScanSchemeTypeAlias } from './scanClassification';

describe('scanClassification', () => {
describe('normalizeScanSchemeTypeAlias', () => {
it('returns null for missing and empty scheme values', () => {
expect(normalizeScanSchemeTypeAlias(null)).toBeNull();
expect(normalizeScanSchemeTypeAlias(undefined)).toBeNull();
expect(normalizeScanSchemeTypeAlias('')).toBeNull();
expect(normalizeScanSchemeTypeAlias(' ')).toBeNull();
});

it('normalizes known web scheme aliases regardless of casing and whitespace', () => {
expect(normalizeScanSchemeTypeAlias('URL')).toBe('WEB');
expect(normalizeScanSchemeTypeAlias(' Url ')).toBe('WEB');
expect(normalizeScanSchemeTypeAlias(' web ')).toBe('WEB');
});

it('preserves unknown schemes after normalization', () => {
expect(normalizeScanSchemeTypeAlias('sms')).toBe('SMS');
expect(normalizeScanSchemeTypeAlias(' ftp ')).toBe('FTP');
});
});

describe('isHttpUrl', () => {
it('accepts http and https URLs across casing and surrounding whitespace', () => {
expect(isHttpUrl('https://example.com')).toBe(true);
expect(isHttpUrl(' HTTP://example.com/path ')).toBe(true);
expect(isHttpUrl('HtTpS://example.com')).toBe(true);
});

it('rejects non-string inputs and non-http schemes', () => {
expect(isHttpUrl(null)).toBe(false);
expect(isHttpUrl(undefined)).toBe(false);
expect(isHttpUrl(42)).toBe(false);
expect(isHttpUrl('ftp://example.com')).toBe(false);
expect(isHttpUrl('mailto:help@example.com')).toBe(false);
});
});

describe('isWebScanTarget', () => {
it('treats URL scheme aliases as web scans even when isUrl is false', () => {
expect(
isWebScanTarget({
isUrl: false,
schemeType: 'URL',
url: 'https://example.com/path',
}),
).toBe(true);
});

it('normalizes scheme casing and whitespace before classification', () => {
expect(
isWebScanTarget({
isUrl: null,
schemeType: ' web ',
url: '',
}),
).toBe(true);
});

it('falls back to the URL when the scheme is null', () => {
expect(
isWebScanTarget({
isUrl: null,
schemeType: null,
url: 'https://example.com/fallback',
}),
).toBe(true);
});

it('prefers an explicit true isUrl flag when scheme and URL are missing', () => {
expect(
isWebScanTarget({
isUrl: true,
schemeType: null,
url: '',
}),
).toBe(true);
});

it('rejects unknown schemes with empty or invalid URLs', () => {
expect(
isWebScanTarget({
isUrl: null,
schemeType: 'sms',
url: '',
}),
).toBe(false);
expect(
isWebScanTarget({
isUrl: null,
schemeType: null,
url: 'mailto:help@example.com',
}),
).toBe(false);
});
});
});
43 changes: 43 additions & 0 deletions src/shared/lib/scan-session/scanClassification.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
type ScanTargetLike = {
isUrl?: boolean | null;
schemeType?: string | null;
url?: string | null;
};

const webSchemeTypeAliases = new Set(['URL', 'WEB']);

export function normalizeScanSchemeTypeAlias(schemeType: string | null | undefined): string | null {
if (!schemeType) {
return null;
}

const normalizedSchemeType = schemeType.trim().toUpperCase();

if (normalizedSchemeType.length === 0) {
return null;
}

return webSchemeTypeAliases.has(normalizedSchemeType) ? 'WEB' : normalizedSchemeType;
}

export function isHttpUrl(url: unknown): boolean {
return typeof url === 'string' && /^https?:\/\//iu.test(url.trim());
}

export function isWebScanTarget({ isUrl, schemeType, url }: ScanTargetLike): boolean {
const normalizedSchemeType = normalizeScanSchemeTypeAlias(schemeType);

if (normalizedSchemeType) {
return normalizedSchemeType === 'WEB';
}

if (isHttpUrl(url)) {
return true;
}

if (typeof isUrl === 'boolean') {
return isUrl;
}

return false;
}
16 changes: 16 additions & 0 deletions src/shared/store/scanSessionStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,4 +50,20 @@ describe('scanSessionStore', () => {
expect(state.schemeType).toBe('SMS');
expect(state.isUrl).toBe(false);
});

it('normalizes URL history selections as web scans', () => {
useScanSessionStore.getState().setHistorySelection({
isUrl: false,
riskLevel: 'safe',
scannedAt: '2026.05.03',
schemeType: 'URL',
url: 'https://example.com/history',
});

const state = useScanSessionStore.getState();

expect(state.decodedUrl).toBe('https://example.com/history');
expect(state.schemeType).toBe('WEB');
expect(state.isUrl).toBe(true);
});
});
27 changes: 18 additions & 9 deletions src/shared/store/scanSessionStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ import type {
BackendScanResponse,
BackendSseFinalPayload,
} from '@/shared/api/types';
import {
isHttpUrl,
normalizeScanSchemeTypeAlias,
} from '@/shared/lib/scan-session/scanClassification';
import type { ResultTone } from '@/shared/types/resultTone';

export type ScanHistorySelection = {
Expand Down Expand Up @@ -85,7 +89,10 @@ function mergePersistedLightSession(
historySelection: persisted.historySelection ?? null,
isUrl: typeof persisted.isUrl === 'boolean' ? persisted.isUrl : null,
riskLevel: persisted.riskLevel ?? null,
schemeType: typeof persisted.schemeType === 'string' ? persisted.schemeType : null,
schemeType:
typeof persisted.schemeType === 'string'
? normalizeScanSchemeTypeAlias(persisted.schemeType)
: null,
};
}

Expand All @@ -108,7 +115,9 @@ function resolveDecodedUrl(source: unknown): string | null {
}

function resolveSchemeType(source: unknown, decodedUrl: string | null): string | null {
const explicitSchemeType = pickString(source, ['schemeType', 'scheme_type']);
const explicitSchemeType = normalizeScanSchemeTypeAlias(
pickString(source, ['schemeType', 'scheme_type']),
);

if (explicitSchemeType) {
return explicitSchemeType;
Expand All @@ -118,7 +127,7 @@ function resolveSchemeType(source: unknown, decodedUrl: string | null): string |
return null;
}

if (/^https?:\/\//iu.test(decodedUrl)) {
if (isHttpUrl(decodedUrl)) {
return 'WEB';
}

Expand All @@ -132,16 +141,16 @@ function resolveIsUrl(
): boolean | null {
const explicitIsUrl = pickBoolean(source, ['isUrl', 'is_url']);

if (explicitIsUrl !== null) {
return explicitIsUrl;
if (schemeType) {
return schemeType === 'WEB';
}

if (schemeType) {
return schemeType.trim().toUpperCase() === 'WEB';
if (isHttpUrl(decodedUrl)) {
return true;
}

if (decodedUrl) {
return /^https?:\/\//iu.test(decodedUrl);
if (explicitIsUrl !== null) {
return explicitIsUrl;
}

return null;
Expand Down
Loading