071faa482c
纳入 AGC Cargo workspace 的统一 rustfmt 检查与格式化入口 完成项目 TypeScript/Prettier 与 Rust 全量格式化 修复 Pingora expected executable 门禁的空白敏感误报 同步开发运维文档与 AGC skill pack 格式化忽略规则
176 lines
5.8 KiB
TypeScript
176 lines
5.8 KiB
TypeScript
import { describe, expect, test, vi } from 'vitest';
|
|
|
|
import { HOST_BRIDGE_EXTERNAL_URL_PROTOCOLS } from '../../../../packages/shared/src/contracts/hostBridge';
|
|
import {
|
|
type MobileShellExternalNavigator,
|
|
openMobileShellExternalNavigation,
|
|
resolveMobileShellExternalUrl,
|
|
resolveMobileShellWebViewUrl,
|
|
shouldAcceptMobileShellHostBridgeMessage,
|
|
shouldOpenInMobileShellWebView,
|
|
} from './navigation';
|
|
|
|
describe('shouldOpenInMobileShellWebView', () => {
|
|
test('只允许主站同源页面留在移动壳 WebView 内', () => {
|
|
const allowedOrigin = 'https://www.genarrative.world';
|
|
|
|
expect(
|
|
shouldOpenInMobileShellWebView(
|
|
'https://www.genarrative.world/works/detail?work=PZ-1',
|
|
allowedOrigin,
|
|
),
|
|
).toBe(true);
|
|
expect(
|
|
shouldOpenInMobileShellWebView('/creation/puzzle', allowedOrigin),
|
|
).toBe(true);
|
|
expect(
|
|
shouldOpenInMobileShellWebView(
|
|
'http://www.genarrative.world/works/detail?work=PZ-1',
|
|
allowedOrigin,
|
|
),
|
|
).toBe(false);
|
|
expect(shouldOpenInMobileShellWebView('about:blank', allowedOrigin)).toBe(
|
|
true,
|
|
);
|
|
});
|
|
|
|
test('外链和非网页协议必须离开带 HostBridge 的 WebView', () => {
|
|
const allowedOrigin = 'https://www.genarrative.world';
|
|
|
|
expect(
|
|
shouldOpenInMobileShellWebView('https://example.com/', allowedOrigin),
|
|
).toBe(false);
|
|
expect(
|
|
shouldOpenInMobileShellWebView('mailto:hi@example.com', allowedOrigin),
|
|
).toBe(false);
|
|
expect(
|
|
shouldOpenInMobileShellWebView('//example.com/evil', allowedOrigin),
|
|
).toBe(false);
|
|
expect(
|
|
shouldOpenInMobileShellWebView('javascript:alert(1)', allowedOrigin),
|
|
).toBe(false);
|
|
expect(shouldOpenInMobileShellWebView('not a url', allowedOrigin)).toBe(
|
|
false,
|
|
);
|
|
});
|
|
|
|
test('只有允许协议能交给系统外部应用打开', () => {
|
|
for (const protocol of HOST_BRIDGE_EXTERNAL_URL_PROTOCOLS) {
|
|
const url =
|
|
protocol === 'mailto:'
|
|
? 'mailto:hi@example.com'
|
|
: protocol === 'tel:'
|
|
? 'tel:+12345678'
|
|
: `${protocol}//example.com/path`;
|
|
expect(resolveMobileShellExternalUrl(` ${url} `)).toBe(url);
|
|
}
|
|
|
|
expect(resolveMobileShellExternalUrl('javascript:alert(1)')).toBeNull();
|
|
expect(resolveMobileShellExternalUrl('file:///etc/passwd')).toBeNull();
|
|
expect(resolveMobileShellExternalUrl('/relative/path')).toBeNull();
|
|
});
|
|
|
|
test('WebView 外链必须先确认系统能打开再离开壳', async () => {
|
|
const navigator = {
|
|
canOpenURL: vi.fn(async (url: string) => url.startsWith('https://')),
|
|
openURL: vi.fn(async () => undefined),
|
|
};
|
|
|
|
await expect(
|
|
openMobileShellExternalNavigation(navigator, 'https://example.com/path'),
|
|
).resolves.toBe(true);
|
|
expect(navigator.canOpenURL).toHaveBeenCalledWith(
|
|
'https://example.com/path',
|
|
);
|
|
expect(navigator.openURL).toHaveBeenCalledWith('https://example.com/path');
|
|
|
|
navigator.canOpenURL.mockClear();
|
|
navigator.openURL.mockClear();
|
|
|
|
await expect(
|
|
openMobileShellExternalNavigation(navigator, 'mailto:hi@example.com'),
|
|
).resolves.toBe(false);
|
|
expect(navigator.canOpenURL).toHaveBeenCalledWith('mailto:hi@example.com');
|
|
expect(navigator.openURL).not.toHaveBeenCalled();
|
|
|
|
navigator.canOpenURL.mockClear();
|
|
navigator.openURL.mockClear();
|
|
|
|
await expect(
|
|
openMobileShellExternalNavigation(navigator, 'javascript:alert(1)'),
|
|
).resolves.toBe(false);
|
|
expect(navigator.canOpenURL).not.toHaveBeenCalled();
|
|
expect(navigator.openURL).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test('WebView 外链原生探测或打开失败时抛给壳层记录', async () => {
|
|
const navigator: MobileShellExternalNavigator = {
|
|
canOpenURL: vi.fn(async () => {
|
|
throw new Error('native canOpenURL failed');
|
|
}),
|
|
openURL: vi.fn(async () => undefined),
|
|
};
|
|
|
|
await expect(
|
|
openMobileShellExternalNavigation(navigator, 'https://example.com/path'),
|
|
).rejects.toThrow('native canOpenURL failed');
|
|
expect(navigator.openURL).not.toHaveBeenCalled();
|
|
|
|
vi.mocked(navigator.canOpenURL).mockReset();
|
|
vi.mocked(navigator.canOpenURL).mockResolvedValue(true);
|
|
vi.mocked(navigator.openURL).mockRejectedValueOnce(
|
|
new Error('native openURL failed'),
|
|
);
|
|
|
|
await expect(
|
|
openMobileShellExternalNavigation(navigator, 'https://example.com/path'),
|
|
).rejects.toThrow('native openURL failed');
|
|
});
|
|
|
|
test('HostBridge 主动导航只解析同源网页目标', () => {
|
|
const allowedOrigin = 'https://www.genarrative.world';
|
|
|
|
expect(
|
|
resolveMobileShellWebViewUrl('/works/detail?work=PZ-1', allowedOrigin),
|
|
).toBe('https://www.genarrative.world/works/detail?work=PZ-1');
|
|
expect(
|
|
resolveMobileShellWebViewUrl(
|
|
'https://www.genarrative.world/creation/puzzle#draft',
|
|
allowedOrigin,
|
|
),
|
|
).toBe('https://www.genarrative.world/creation/puzzle#draft');
|
|
expect(
|
|
resolveMobileShellWebViewUrl('https://example.com/', allowedOrigin),
|
|
).toBeNull();
|
|
expect(
|
|
resolveMobileShellWebViewUrl('about:blank', allowedOrigin),
|
|
).toBeNull();
|
|
});
|
|
|
|
test('HostBridge 消息只接受同源主站页面', () => {
|
|
const allowedOrigin = 'https://www.genarrative.world';
|
|
|
|
expect(
|
|
shouldAcceptMobileShellHostBridgeMessage(
|
|
'https://www.genarrative.world/creation/puzzle',
|
|
allowedOrigin,
|
|
),
|
|
).toBe(true);
|
|
expect(
|
|
shouldAcceptMobileShellHostBridgeMessage('about:blank', allowedOrigin),
|
|
).toBe(false);
|
|
expect(
|
|
shouldAcceptMobileShellHostBridgeMessage(
|
|
'https://example.com/evil',
|
|
allowedOrigin,
|
|
),
|
|
).toBe(false);
|
|
expect(
|
|
shouldAcceptMobileShellHostBridgeMessage(
|
|
'javascript:alert(1)',
|
|
allowedOrigin,
|
|
),
|
|
).toBe(false);
|
|
});
|
|
});
|