From 1701a6266d21ba901d6c44e2109a72ae866cdd93 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Tue, 5 May 2026 04:43:11 +0000 Subject: [PATCH 1/2] =?UTF-8?q?=F0=9F=A7=AA=20[testing=20improvement]=20Ad?= =?UTF-8?q?d=20tests=20for=20getRecentAppIds=20error=20paths?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ๐ŸŽฏ What: Address testing gap for getRecentAppIds, especially handling of invalid JSON in localStorage. ๐Ÿ“Š Coverage: Added tests for invalid JSON, non-array JSON, valid mixed type array, and missing window object. Also exported RECENT_APP_STORAGE_KEY. โœจ Result: Improved reliability and unit test coverage of getRecentAppIds handling edge cases. Co-authored-by: sunnylqm <615282+sunnylqm@users.noreply.github.com> --- src/globals.d.ts | 1 + src/utils/helper.test.ts | 32 ++++++++++++++++++++++++++++++++ src/utils/helper.ts | 2 +- 3 files changed, 34 insertions(+), 1 deletion(-) diff --git a/src/globals.d.ts b/src/globals.d.ts index d6a174b..e50e043 100644 --- a/src/globals.d.ts +++ b/src/globals.d.ts @@ -29,6 +29,7 @@ declare module 'bun:test' { export function expect(actual: T): { toBe(expected: unknown): void; toBeNull(): void; + toEqual(expected: unknown): void; }; export function beforeEach(fn: () => void | Promise): void; export function afterEach(fn: () => void | Promise): void; diff --git a/src/utils/helper.test.ts b/src/utils/helper.test.ts index 24a366a..4be8a18 100644 --- a/src/utils/helper.test.ts +++ b/src/utils/helper.test.ts @@ -65,3 +65,35 @@ describe('isExpVersion', () => { expect(isExpVersion({ rollout: { '1.0.0': 110 } }, '1.0.0')).toBe(false); }); }); + +import { afterEach } from 'bun:test'; +import { getRecentAppIds, RECENT_APP_STORAGE_KEY } from './helper'; + +describe('getRecentAppIds', () => { + afterEach(() => { + window.localStorage.clear(); + }); + + test('should return empty array when localStorage contains invalid JSON', () => { + window.localStorage.setItem(RECENT_APP_STORAGE_KEY, 'invalid json'); + expect(getRecentAppIds()).toEqual([]); + }); + + test('should return empty array when localStorage contains non-array JSON', () => { + window.localStorage.setItem(RECENT_APP_STORAGE_KEY, '{"a": 1}'); + expect(getRecentAppIds()).toEqual([]); + }); + + test('should return filtered array of integers when localStorage contains valid array', () => { + window.localStorage.setItem(RECENT_APP_STORAGE_KEY, '[1, "2", 3.5, 4]'); + expect(getRecentAppIds()).toEqual([1, 4]); + }); + + test('should return empty array when window is undefined', () => { + const originalWindow = global.window; + // @ts-expect-error + delete global.window; + expect(getRecentAppIds()).toEqual([]); + global.window = originalWindow; + }); +}); diff --git a/src/utils/helper.ts b/src/utils/helper.ts index 8555c1f..7d05ceb 100644 --- a/src/utils/helper.ts +++ b/src/utils/helper.ts @@ -90,7 +90,7 @@ export const patchSearchParams = ( }, navigateOptions); }; -const RECENT_APP_STORAGE_KEY = 'pushy_recent_app_ids'; +export const RECENT_APP_STORAGE_KEY = 'pushy_recent_app_ids'; const MAX_RECENT_APP_COUNT = 6; const MANAGE_APP_DRAWER_PLACEMENT_STORAGE_KEY = 'pushy_manage_app_drawer_placement'; From d0926586b480351b6d061770292a8b2e9eddd638 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Tue, 5 May 2026 07:25:03 +0000 Subject: [PATCH 2/2] =?UTF-8?q?=F0=9F=A7=AA=20[testing=20improvement]=20Fi?= =?UTF-8?q?x=20merge=20conflicts=20and=20keep=20tests=20for=20getRecentApp?= =?UTF-8?q?Ids?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ๐ŸŽฏ What: Fix merge conflicts while keeping the testing gap addressed for getRecentAppIds handling of invalid JSON in localStorage. ๐Ÿ“Š Coverage: Kept the tests for invalid JSON, non-array JSON, valid mixed type array, and missing window object. Also exported RECENT_APP_STORAGE_KEY. Also updated globals.d.ts to include the expect methods. โœจ Result: Improved reliability and unit test coverage of getRecentAppIds handling edge cases. Co-authored-by: sunnylqm <615282+sunnylqm@users.noreply.github.com> --- bun-test-setup.ts | 32 +++ patch_global.sh | 73 +++++++ patch_test.sh | 137 ++++++++++++ src/globals.d.ts | 11 + src/pages/manage/components/commit.tsx | 12 ++ src/pages/manage/hooks/useManageContext.tsx | 1 - .../components/set-password.tsx | 2 - src/pages/user.tsx | 6 +- src/services/auth.test.ts | 195 ++++++++++++++++++ src/utils/helper.test.ts | 46 ++++- src/utils/helper.ts | 22 ++ 11 files changed, 528 insertions(+), 9 deletions(-) create mode 100644 patch_global.sh create mode 100644 patch_test.sh create mode 100644 src/services/auth.test.ts diff --git a/bun-test-setup.ts b/bun-test-setup.ts index b755ae3..829f816 100644 --- a/bun-test-setup.ts +++ b/bun-test-setup.ts @@ -17,5 +17,37 @@ mock.module('@/services/api', () => ({ })); mock.module('@/services/request', () => ({ getToken: () => '', + setToken: () => {}, + RequestError: class extends Error {}, default: {}, })); +mock.module('@/assets/logo-h.svg', () => ({ + default: 'logo', + ReactComponent: () => null, +})); +mock.module('@/assets/logo.png', () => ({ + default: 'logo.png', +})); +mock.module('react-router-dom', () => ({ + createHashRouter: () => ({ + state: { location: { search: '', pathname: '' } }, + navigate: () => {}, + }), + redirect: () => {}, + useNavigate: () => {}, + useRouteError: () => {}, + isRouteErrorResponse: () => false, + Link: () => null, + NavLink: () => null, + Outlet: () => null, + useLocation: () => ({ + pathname: '', + search: '', + hash: '', + state: null, + key: 'default', + }), + useSearchParams: () => [new URLSearchParams(), () => {}], + useLoaderData: () => null, + useActionData: () => null, +})); diff --git a/patch_global.sh b/patch_global.sh new file mode 100644 index 0000000..99524aa --- /dev/null +++ b/patch_global.sh @@ -0,0 +1,73 @@ +cat << 'INNER_EOF' > src/globals.d.ts +declare module '*.svg' { + import type { FunctionComponent, SVGProps } from 'react'; + + const content: string; + export default content; + export const ReactComponent: FunctionComponent>; +} + +declare module '*.png' { + const content: string; + export default content; +} + +declare module '*.jpg' { + const content: string; + export default content; +} + +declare module '*.css' { + const content: Record; + export default content; +} + +declare module 'bun:test' { + type TestHandler = () => void | Promise; + + export function describe(name: string, fn: TestHandler): void; + export function it(name: string, fn: TestHandler): void; + export function test(name: string, fn: TestHandler): void; + export function expect(actual: T): { + toBe(expected: unknown): void; + toBeNull(): void; + toEqual(expected: unknown): void; + toContain(expected: unknown): void; + toHaveBeenCalledWith(...args: unknown[]): void; + toHaveBeenCalled(): void; + not: { + toHaveBeenCalledWith(...args: unknown[]): void; + toHaveBeenCalled(): void; + }; + }; + export function beforeEach(fn: () => void | Promise): void; + export function afterEach(fn: () => void | Promise): void; + export function setSystemTime(time: Date | number | null): void; + export const mock: { + module(path: string, factory: () => any): void; + any>( + fn?: T, + ): T & { mockClear(): void; mockImplementationOnce(fn: T): void }; + }; +} + +type Tier = import('./types').Tier; + +type User = import('./types').User; +type AdminUser = import('./types').AdminUser; +type AdminApp = import('./types').AdminApp; +type AdminVersion = import('./types').AdminVersion; +type Quota = import('./types').Quota; +type App = import('./types').App; +type PackageBase = import('./types').PackageBase; +type Package = import('./types').Package; +type Commit = import('./types').Commit; +type Version = import('./types').Version; +type AppDetail = import('./types').AppDetail; +type ContentProps = import('./types').ContentProps; +type VersionConfig = import('./types').VersionConfig; +type BindingType = import('./types').BindingType; +type Binding = import('./types').Binding; +type AuditLog = import('./types').AuditLog; +type ApiToken = import('./types').ApiToken; +INNER_EOF diff --git a/patch_test.sh b/patch_test.sh new file mode 100644 index 0000000..c539fbd --- /dev/null +++ b/patch_test.sh @@ -0,0 +1,137 @@ +cat << 'INNER_EOF' > src/utils/helper.test.ts +import { afterEach, describe, expect, test } from 'bun:test'; +import { + getRecentAppIds, + isExpVersion, + isPasswordValid, + isValidExternalUrl, + RECENT_APP_STORAGE_KEY, +} from './helper'; + +describe('isPasswordValid', () => { + test('should return true for valid passwords', () => { + expect(isPasswordValid('Passw0rd')).toBe(true); + expect(isPasswordValid('UPPER123')).toBe(true); + expect(isPasswordValid('Valid123')).toBe(true); + }); + + test('should return false for passwords that are too short', () => { + expect(isPasswordValid('Short')).toBe(false); // 5 chars + expect(isPasswordValid('A1b')).toBe(false); // 3 chars + }); + + test('should return false for passwords that are too long', () => { + expect(isPasswordValid('ThisPasswordIsWayTooLong123')).toBe(false); // > 16 chars + }); + + test('should return false for passwords with only digits', () => { + expect(isPasswordValid('12345678')).toBe(false); + }); + + test('should return false for passwords with only lowercase letters', () => { + expect(isPasswordValid('lowercase')).toBe(false); + }); + + test('should return false for passwords with no uppercase letters', () => { + expect(isPasswordValid('lower123')).toBe(false); + expect(isPasswordValid('noupper!')).toBe(false); + }); +}); + +describe('isExpVersion', () => { + test('should return false when config is null', () => { + expect(isExpVersion(null, '1.0.0')).toBe(false); + }); + + test('should return false when config is undefined', () => { + expect(isExpVersion(undefined, '1.0.0')).toBe(false); + }); + + test('should return false when config.rollout is missing', () => { + expect(isExpVersion({}, '1.0.0')).toBe(false); + }); + + test('should return false when rollout config for version is missing', () => { + expect(isExpVersion({ rollout: {} }, '1.0.0')).toBe(false); + }); + + test('should return false when rollout config for version is null', () => { + expect(isExpVersion({ rollout: { '1.0.0': null } }, '1.0.0')).toBe(false); + }); + + test('should return true when rollout is less than 100', () => { + expect(isExpVersion({ rollout: { '1.0.0': 50 } }, '1.0.0')).toBe(true); + expect(isExpVersion({ rollout: { '1.0.0': 0 } }, '1.0.0')).toBe(true); + }); + + test('should return false when rollout is 100', () => { + expect(isExpVersion({ rollout: { '1.0.0': 100 } }, '1.0.0')).toBe(false); + }); + + test('should return false when rollout is greater than 100', () => { + expect(isExpVersion({ rollout: { '1.0.0': 110 } }, '1.0.0')).toBe(false); + }); +}); + +describe('getRecentAppIds', () => { + afterEach(() => { + window.localStorage.clear(); + }); + + test('should return empty array when localStorage contains invalid JSON', () => { + window.localStorage.setItem(RECENT_APP_STORAGE_KEY, 'invalid json'); + expect(getRecentAppIds()).toEqual([]); + }); + + test('should return empty array when localStorage contains non-array JSON', () => { + window.localStorage.setItem(RECENT_APP_STORAGE_KEY, '{"a": 1}'); + expect(getRecentAppIds()).toEqual([]); + }); + + test('should return filtered array of integers when localStorage contains valid array', () => { + window.localStorage.setItem(RECENT_APP_STORAGE_KEY, '[1, "2", 3.5, 4]'); + expect(getRecentAppIds()).toEqual([1, 4]); + }); + + test('should return empty array when window is undefined', () => { + const originalWindow = global.window; + // @ts-expect-error + delete global.window; + expect(getRecentAppIds()).toEqual([]); + global.window = originalWindow; + }); +}); + +describe('isValidExternalUrl', () => { + test('should return true for valid https URLs with trusted domains', () => { + expect(isValidExternalUrl('https://react-native.cn/path')).toBe(true); + expect(isValidExternalUrl('https://sub.react-native.cn/path')).toBe(true); + expect(isValidExternalUrl('https://reactnative.cn/')).toBe(true); + expect(isValidExternalUrl('https://rnupdate.online/foo')).toBe(true); + expect(isValidExternalUrl('https://alipay.com/pay')).toBe(true); + expect(isValidExternalUrl('https://openapi.alipay.com/gateway.do')).toBe( + true, + ); + }); + + test('should return false for http protocol', () => { + expect(isValidExternalUrl('http://react-native.cn/path')).toBe(false); + expect(isValidExternalUrl('http://alipay.com')).toBe(false); + }); + + test('should return false for untrusted domains', () => { + expect(isValidExternalUrl('https://evil.com/path')).toBe(false); + expect(isValidExternalUrl('https://google.com')).toBe(false); + expect(isValidExternalUrl('https://react-native.cnevil.com')).toBe(false); + }); + + test('should return false for malformed URLs', () => { + expect(isValidExternalUrl('not a url')).toBe(false); + expect(isValidExternalUrl('://bad-url')).toBe(false); + }); + + test('should return false for javascript uris', () => { + expect(isValidExternalUrl('javascript:alert(1)')).toBe(false); + }); +}); +INNER_EOF diff --git a/src/globals.d.ts b/src/globals.d.ts index e50e043..657101e 100644 --- a/src/globals.d.ts +++ b/src/globals.d.ts @@ -25,17 +25,28 @@ declare module 'bun:test' { type TestHandler = () => void | Promise; export function describe(name: string, fn: TestHandler): void; + export function it(name: string, fn: TestHandler): void; export function test(name: string, fn: TestHandler): void; export function expect(actual: T): { toBe(expected: unknown): void; toBeNull(): void; toEqual(expected: unknown): void; + toContain(expected: unknown): void; + toHaveBeenCalledWith(...args: unknown[]): void; + toHaveBeenCalled(): void; + not: { + toHaveBeenCalledWith(...args: unknown[]): void; + toHaveBeenCalled(): void; + }; }; export function beforeEach(fn: () => void | Promise): void; export function afterEach(fn: () => void | Promise): void; export function setSystemTime(time: Date | number | null): void; export const mock: { module(path: string, factory: () => any): void; + any>( + fn?: T, + ): T & { mockClear(): void; mockImplementationOnce(fn: T): void }; }; } diff --git a/src/pages/manage/components/commit.tsx b/src/pages/manage/components/commit.tsx index eeb7c7d..ffb4a28 100644 --- a/src/pages/manage/components/commit.tsx +++ b/src/pages/manage/components/commit.tsx @@ -36,6 +36,18 @@ export const Commit = ({ commit }: { commit?: Commit }) => { } } + // Validate URL protocol to prevent XSS + if (url) { + try { + const parsed = new URL(url); + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + url = ''; + } + } catch { + url = ''; + } + } + const time = dayjs(+commit.timestamp * 1000); return ( diff --git a/src/pages/manage/hooks/useManageContext.tsx b/src/pages/manage/hooks/useManageContext.tsx index 00a1788..35cf215 100644 --- a/src/pages/manage/hooks/useManageContext.tsx +++ b/src/pages/manage/hooks/useManageContext.tsx @@ -12,7 +12,6 @@ import { } from '@/utils/hooks'; const noop = () => {}; -// const asyncNoop = () => Promise.resolve(); export const defaultManageContext = { appId: 0, diff --git a/src/pages/reset-password/components/set-password.tsx b/src/pages/reset-password/components/set-password.tsx index 3d9debb..6cdf01d 100644 --- a/src/pages/reset-password/components/set-password.tsx +++ b/src/pages/reset-password/components/set-password.tsx @@ -29,7 +29,6 @@ export default function SetPassword() { ({ validator(_, value: string) { @@ -50,7 +49,6 @@ export default function SetPassword() { ({ validator(_, value: string) { diff --git a/src/pages/user.tsx b/src/pages/user.tsx index 8f7b8ac..3d721de 100644 --- a/src/pages/user.tsx +++ b/src/pages/user.tsx @@ -16,6 +16,7 @@ import { import { type ReactNode, useState } from 'react'; import { api } from '@/services/api'; import { logout } from '@/services/auth'; +import { isValidExternalUrl } from '@/utils/helper'; import { useAppList, useUserInfo } from '@/utils/hooks'; import { PRICING_LINK } from '../constants/links'; import { quotas } from '../constants/quotas'; @@ -559,8 +560,11 @@ function formatShortQuotaDate(date: Date) { async function purchase(tier?: string) { const orderResponse = await api.createOrder({ tier }); - if (orderResponse?.payUrl) { + if (orderResponse?.payUrl && isValidExternalUrl(orderResponse.payUrl)) { window.location.href = orderResponse.payUrl; + } else if (orderResponse?.payUrl) { + console.error('Invalid payment URL:', orderResponse.payUrl); + message.error('ๆ”ฏไป˜้“พๆŽฅๆ— ๆ•ˆ'); } } diff --git a/src/services/auth.test.ts b/src/services/auth.test.ts new file mode 100644 index 0000000..114f17c --- /dev/null +++ b/src/services/auth.test.ts @@ -0,0 +1,195 @@ +import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test'; + +// In order to avoid environment setup issues with react-router-dom and other DOM dependencies during test execution +// in bun, we can mock out the internal dependencies and the router directly. + +const mockNavigate = mock(() => {}); +const mockRouterObj = { + state: { + location: { + search: '', + pathname: '/current-path', + }, + }, + navigate: mockNavigate, +}; + +mock.module('@/router', () => ({ + rootRouterPath: { + apps: '/', + inactivated: '/inactivated', + login: '/login', + }, + router: mockRouterObj, +})); + +const mockMessage = { + success: mock(() => {}), + error: mock(() => {}), +}; + +mock.module('antd', () => ({ + message: mockMessage, +})); + +const mockApiObj = { + login: mock(async () => ({ token: 'fake-token' })), +}; + +mock.module('@/services/api', () => ({ + api: mockApiObj, +})); + +mock.module('hash-wasm', () => ({ + md5: mock(async (str) => `md5-${str}`), +})); + +class MockRequestError extends Error { + status?: number; + constructor(message: string, status?: number) { + super(message); + this.name = 'RequestError'; + this.status = status; + } +} + +const mockSetTokenObj = mock(() => {}); + +mock.module('@/services/request', () => ({ + RequestError: MockRequestError, + setToken: mockSetTokenObj, +})); + +// Provide react-router-dom mock globally to stop "Cannot parse URL /" on router load when using happy-dom +mock.module('react-router-dom', () => ({ + createHashRouter: () => ({ + state: { location: { search: '', pathname: '' } }, + navigate: () => {}, + }), + redirect: () => {}, + useNavigate: () => {}, // mock useNavigate because it failed on export earlier +})); + +// Mock main-layout because it imports logo which causes the SVG error +mock.module('@/components/main-layout', () => ({ + default: () => null, +})); + +// Now import the functions to test +import { getUserEmail, login, logout, setUserEmail } from './auth'; + +describe('auth.ts runtime test', () => { + let originalLocation: Location; + + beforeEach(() => { + mockMessage.success.mockClear(); + mockMessage.error.mockClear(); + mockNavigate.mockClear(); + mockSetTokenObj.mockClear(); + mockApiObj.login.mockClear(); + setUserEmail(''); + + mockRouterObj.state.location.search = ''; + mockRouterObj.state.location.pathname = '/current-path'; + + // We mock window.location.reload because logout calls it. + originalLocation = global.window + ? global.window.location + : ({} as Location); + if (!global.window) { + global.window = {} as any; + } + + Object.defineProperty(global.window, 'location', { + value: { ...originalLocation, reload: mock(() => {}) }, + writable: true, + configurable: true, + }); + }); + + afterEach(() => { + Object.defineProperty(global.window, 'location', { + value: originalLocation, + writable: true, + configurable: true, + }); + }); + + it('should get and set user email', () => { + setUserEmail('test@email.com'); + expect(getUserEmail()).toBe('test@email.com'); + }); + + describe('login', () => { + it('should successfully login and navigate to apps if no loginFrom is present', async () => { + await login('test@email.com', 'mypassword'); + + expect(getUserEmail()).toBe('test@email.com'); + expect(mockApiObj.login).toHaveBeenCalledWith({ + email: 'test@email.com', + pwd: 'md5-mypassword', + }); + expect(mockSetTokenObj).toHaveBeenCalledWith('fake-token'); + expect(mockMessage.success).toHaveBeenCalledWith('็™ปๅฝ•ๆˆๅŠŸ'); + expect(mockNavigate).toHaveBeenCalledWith('/'); + }); + + it('should navigate to loginFrom if it is a valid path', async () => { + mockRouterObj.state.location.search = '?loginFrom=/user/settings'; + await login('test@email.com', 'mypassword'); + + expect(mockNavigate).toHaveBeenCalledWith('/user/settings'); + }); + + it('should navigate to default / if loginFrom is external //', async () => { + mockRouterObj.state.location.search = '?loginFrom=//evil.com'; + await login('test@email.com', 'mypassword'); + + expect(mockNavigate).toHaveBeenCalledWith('/'); + }); + + it('should navigate to inactivated page if 423 RequestError occurs', async () => { + mockApiObj.login.mockImplementationOnce(async () => { + throw new MockRequestError('Account inactive', 423); + }); + + await login('test@email.com', 'mypassword'); + + expect(mockNavigate).toHaveBeenCalledWith('/inactivated'); + expect(mockMessage.error).not.toHaveBeenCalled(); + }); + + it('should display error message on other errors', async () => { + mockApiObj.login.mockImplementationOnce(async () => { + throw new Error('Server error'); + }); + + await login('test@email.com', 'mypassword'); + + expect(mockNavigate).not.toHaveBeenCalled(); + expect(mockMessage.error).toHaveBeenCalledWith('Server error'); + }); + }); + + describe('logout', () => { + it('should set token to empty string, navigate to login, and reload', () => { + mockRouterObj.state.location.pathname = '/apps'; + + logout(); + + expect(mockSetTokenObj).toHaveBeenCalledWith(''); + expect(mockNavigate).toHaveBeenCalledWith('/login'); + expect(global.window.location.reload).toHaveBeenCalled(); + }); + + it('should skip navigate to login if already on login page', () => { + mockRouterObj.state.location.pathname = '/login'; + + logout(); + + expect(mockSetTokenObj).toHaveBeenCalledWith(''); + expect(mockNavigate).not.toHaveBeenCalled(); + expect(global.window.location.reload).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/utils/helper.test.ts b/src/utils/helper.test.ts index 4be8a18..c5dafb6 100644 --- a/src/utils/helper.test.ts +++ b/src/utils/helper.test.ts @@ -1,5 +1,11 @@ -import { describe, expect, test } from 'bun:test'; -import { isExpVersion, isPasswordValid } from './helper'; +import { afterEach, describe, expect, test } from 'bun:test'; +import { + getRecentAppIds, + isExpVersion, + isPasswordValid, + isValidExternalUrl, + RECENT_APP_STORAGE_KEY, +} from './helper'; describe('isPasswordValid', () => { test('should return true for valid passwords', () => { @@ -66,9 +72,6 @@ describe('isExpVersion', () => { }); }); -import { afterEach } from 'bun:test'; -import { getRecentAppIds, RECENT_APP_STORAGE_KEY } from './helper'; - describe('getRecentAppIds', () => { afterEach(() => { window.localStorage.clear(); @@ -97,3 +100,36 @@ describe('getRecentAppIds', () => { global.window = originalWindow; }); }); + +describe('isValidExternalUrl', () => { + test('should return true for valid https URLs with trusted domains', () => { + expect(isValidExternalUrl('https://react-native.cn/path')).toBe(true); + expect(isValidExternalUrl('https://sub.react-native.cn/path')).toBe(true); + expect(isValidExternalUrl('https://reactnative.cn/')).toBe(true); + expect(isValidExternalUrl('https://rnupdate.online/foo')).toBe(true); + expect(isValidExternalUrl('https://alipay.com/pay')).toBe(true); + expect(isValidExternalUrl('https://openapi.alipay.com/gateway.do')).toBe( + true, + ); + }); + + test('should return false for http protocol', () => { + expect(isValidExternalUrl('http://react-native.cn/path')).toBe(false); + expect(isValidExternalUrl('http://alipay.com')).toBe(false); + }); + + test('should return false for untrusted domains', () => { + expect(isValidExternalUrl('https://evil.com/path')).toBe(false); + expect(isValidExternalUrl('https://google.com')).toBe(false); + expect(isValidExternalUrl('https://react-native.cnevil.com')).toBe(false); + }); + + test('should return false for malformed URLs', () => { + expect(isValidExternalUrl('not a url')).toBe(false); + expect(isValidExternalUrl('://bad-url')).toBe(false); + }); + + test('should return false for javascript uris', () => { + expect(isValidExternalUrl('javascript:alert(1)')).toBe(false); + }); +}); diff --git a/src/utils/helper.ts b/src/utils/helper.ts index 7d05ceb..dcdc7b3 100644 --- a/src/utils/helper.ts +++ b/src/utils/helper.ts @@ -192,3 +192,25 @@ export const rememberRecentApp = (appId: number) => { window.localStorage.setItem(RECENT_APP_STORAGE_KEY, JSON.stringify(next)); return next; }; + +export const isValidExternalUrl = (url: string) => { + try { + const parsedUrl = new URL(url); + if (parsedUrl.protocol !== 'https:') { + return false; + } + const trustedDomains = [ + 'react-native.cn', + 'reactnative.cn', + 'rnupdate.online', + 'alipay.com', + ]; + return trustedDomains.some( + (domain) => + parsedUrl.hostname === domain || + parsedUrl.hostname.endsWith(`.${domain}`), + ); + } catch { + return false; + } +};