Skip to content
Open
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
32 changes: 32 additions & 0 deletions bun-test-setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}));
73 changes: 73 additions & 0 deletions patch_global.sh
Original file line number Diff line number Diff line change
@@ -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<SVGProps<SVGSVGElement>>;
}

declare module '*.png' {
const content: string;
export default content;
}

declare module '*.jpg' {
const content: string;
export default content;
}

declare module '*.css' {
const content: Record<string, string>;
export default content;
}

declare module 'bun:test' {
type TestHandler = () => void | Promise<void>;

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<T>(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>): void;
export function afterEach(fn: () => void | Promise<void>): void;
export function setSystemTime(time: Date | number | null): void;
export const mock: {
module(path: string, factory: () => any): void;
<T extends (...args: any[]) => 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
137 changes: 137 additions & 0 deletions patch_test.sh
Original file line number Diff line number Diff line change
@@ -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
12 changes: 12 additions & 0 deletions src/globals.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,16 +25,28 @@ declare module 'bun:test' {
type TestHandler = () => void | Promise<void>;

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<T>(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>): void;
export function afterEach(fn: () => void | Promise<void>): void;
export function setSystemTime(time: Date | number | null): void;
export const mock: {
module(path: string, factory: () => any): void;
<T extends (...args: any[]) => any>(
fn?: T,
): T & { mockClear(): void; mockImplementationOnce(fn: T): void };
};
}

Expand Down
12 changes: 12 additions & 0 deletions src/pages/manage/components/commit.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down
1 change: 0 additions & 1 deletion src/pages/manage/hooks/useManageContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ import {
} from '@/utils/hooks';

const noop = () => {};
// const asyncNoop = () => Promise.resolve();

export const defaultManageContext = {
appId: 0,
Expand Down
2 changes: 0 additions & 2 deletions src/pages/reset-password/components/set-password.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,6 @@ export default function SetPassword() {
<Form.Item
hasFeedback
name="newPwd"
// validateTrigger='onBlur'
rules={[
() => ({
validator(_, value: string) {
Expand All @@ -50,7 +49,6 @@ export default function SetPassword() {
<Form.Item
hasFeedback
name="pwd2"
// validateTrigger='onBlur'
rules={[
({ getFieldValue }) => ({
validator(_, value: string) {
Expand Down
6 changes: 5 additions & 1 deletion src/pages/user.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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('ζ”―δ»˜ι“ΎζŽ₯ζ— ζ•ˆ');
}
Comment on lines +563 to 568
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚑ Quick win

Reset button loading state when payment URL is invalid/missing.

This branch can return without redirect, but callers keep loading=true, so purchase buttons can remain stuck after a rejected URL.

Proposed fix
-async function purchase(tier?: string) {
+async function purchase(tier?: string): Promise<boolean> {
   const orderResponse = await api.createOrder({ tier });
   if (orderResponse?.payUrl && isValidExternalUrl(orderResponse.payUrl)) {
     window.location.href = orderResponse.payUrl;
-  } else if (orderResponse?.payUrl) {
+    return true;
+  }
+  if (orderResponse?.payUrl) {
     console.error('Invalid payment URL:', orderResponse.payUrl);
-    message.error('ζ”―δ»˜ι“ΎζŽ₯ζ— ζ•ˆ');
+  }
+  message.error('ζ”―δ»˜ι“ΎζŽ₯ζ— ζ•ˆ');
+  return false;
-  }
 }
// PurchaseButton onClick
- setLoading(true);
- await purchase(tier);
+ setLoading(true);
+ try {
+   await purchase(tier);
+ } finally {
+   setLoading(false);
+ }

// UpgradeDropdown handlers (both call sites)
- setLoading(true);
- await purchase(key);
+ setLoading(true);
+ try {
+   await purchase(key);
+ } finally {
+   setLoading(false);
+ }
πŸ€– Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/pages/user.tsx` around lines 563 - 568, The branch that checks
orderResponse?.payUrl may return without redirect but doesn't reset the button
loading state; update the else/invalid branch (the block that logs 'Invalid
payment URL' and calls message.error) to also clear the pending/loading flag
used by the purchase flow (e.g., call the same setter used when starting the
purchase such as setLoading(false) or setIsPurchasing(false)). Ensure you place
the call in both the invalid-URL branch and the case where payUrl is missing so
the UI button isn't left stuck; reference the existing orderResponse?.payUrl
check and isValidExternalUrl(...) call when adding the reset.

}

Expand Down
Loading