Files
Genarrative/apps/mobile-shell/src/shell/webViewPolicy.test.ts
T
kdletters b6b1e21211 统一移动下载协议阻断清单
将移动 WebView 禁止下载协议提升到共享 HostBridge 契约

让移动壳导航拦截和注入脚本复用共享协议清单

增加移动壳门禁和测试防止协议清单回退到本地字面量

补充宿主壳方案文档和共享决策记录
2026-06-19 14:27:34 +08:00

270 lines
8.1 KiB
TypeScript

/* @vitest-environment jsdom */
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import { HOST_BRIDGE_MOBILE_WEBVIEW_BLOCKED_DOWNLOAD_PROTOCOLS } from '../../../../packages/shared/src/contracts/hostBridge';
import {
BLOCK_WEBVIEW_DOWNLOAD_SCRIPT,
MOBILE_WEBVIEW_BEFORE_CONTENT_SCRIPT,
shouldBlockMobileWebViewDownloadUrl,
shouldBlockMobileWebViewNavigationRequest,
TRACK_MOBILE_WEBVIEW_HISTORY_SCRIPT,
} from './webViewPolicy';
describe('BLOCK_WEBVIEW_DOWNLOAD_SCRIPT', () => {
const originalOpen = window.open;
const originalAnchorClick = HTMLAnchorElement.prototype.click;
beforeEach(() => {
document.body.innerHTML = '';
window.open = vi.fn(() => null) as typeof window.open;
HTMLAnchorElement.prototype.click = originalAnchorClick;
window.eval(BLOCK_WEBVIEW_DOWNLOAD_SCRIPT);
});
afterEach(() => {
document.body.innerHTML = '';
window.open = originalOpen;
vi.restoreAllMocks();
HTMLAnchorElement.prototype.click = originalAnchorClick;
});
test('保留 WebView 注入脚本返回值', () => {
expect(BLOCK_WEBVIEW_DOWNLOAD_SCRIPT.trim()).toMatch(/true;$/);
expect(TRACK_MOBILE_WEBVIEW_HISTORY_SCRIPT.trim()).toMatch(/true;$/);
expect(MOBILE_WEBVIEW_BEFORE_CONTENT_SCRIPT.trim()).toMatch(/true;$/);
});
test('注入脚本复用共享下载协议阻断清单', () => {
for (const protocol of HOST_BRIDGE_MOBILE_WEBVIEW_BLOCKED_DOWNLOAD_PROTOCOLS) {
expect(BLOCK_WEBVIEW_DOWNLOAD_SCRIPT).toContain(
`${JSON.stringify(protocol)}: true`,
);
expect(shouldBlockMobileWebViewDownloadUrl(`${protocol}download-id`)).toBe(
true,
);
}
});
test('阻断嵌套元素触发的下载链接点击', () => {
document.body.innerHTML = `
<a download>
<span id="nested-download-target">保存</span>
</a>
`;
const target = document.getElementById('nested-download-target');
expect(target).toBeTruthy();
const event = new MouseEvent('click', {
bubbles: true,
cancelable: true,
});
const allowed = target?.dispatchEvent(event);
expect(allowed).toBe(false);
expect(event.defaultPrevented).toBe(true);
});
test('放行非下载锚点点击', () => {
document.body.innerHTML = `
<a>
<span id="nested-page-target">打开</span>
</a>
`;
const target = document.getElementById('nested-page-target');
expect(target).toBeTruthy();
let defaultPreventedBeforeTarget = true;
target?.addEventListener('click', (event) => {
defaultPreventedBeforeTarget = event.defaultPrevented;
event.preventDefault();
});
const event = new MouseEvent('click', {
bubbles: true,
cancelable: true,
});
const allowed = target?.dispatchEvent(event);
expect(allowed).toBe(false);
expect(defaultPreventedBeforeTarget).toBe(false);
expect(event.defaultPrevented).toBe(true);
});
test('阻断危险下载协议的窗口打开', () => {
const opened = window.open('blob:https://app.genarrative.world/file-id');
expect(opened).toBeNull();
});
test('阻断程序化下载链接点击', () => {
const originalClickSpy = vi.spyOn(
HTMLAnchorElement.prototype,
'click',
);
window.eval(BLOCK_WEBVIEW_DOWNLOAD_SCRIPT);
const anchor = document.createElement('a');
anchor.download = 'hello.txt';
anchor.click();
expect(originalClickSpy).not.toHaveBeenCalled();
});
});
describe('TRACK_MOBILE_WEBVIEW_HISTORY_SCRIPT', () => {
const originalPushState = window.history.pushState;
const originalReplaceState = window.history.replaceState;
beforeEach(() => {
window.history.pushState = originalPushState;
window.history.replaceState = originalReplaceState;
delete window.__GENARRATIVE_MOBILE_HISTORY_TRACKER_INSTALLED__;
window.history.replaceState(null, '', '/');
});
afterEach(() => {
delete window.ReactNativeWebView;
delete window.__GENARRATIVE_MOBILE_HISTORY_TRACKER_INSTALLED__;
window.history.pushState = originalPushState;
window.history.replaceState = originalReplaceState;
});
test('追踪 H5 当前文档路由栈并上报返回状态', () => {
const postMessage = vi.fn();
window.ReactNativeWebView = {
postMessage,
};
window.eval(TRACK_MOBILE_WEBVIEW_HISTORY_SCRIPT);
const initialState = window.history.state;
window.history.pushState({ route: 'detail' }, '', '/works/detail');
window.history.replaceState({ route: 'detail-updated' }, '', '/works/detail?tab=info');
window.dispatchEvent(new PopStateEvent('popstate', {
state: initialState,
}));
expect(postMessage).toHaveBeenNthCalledWith(1,
JSON.stringify({
type: 'genarrative.mobile.historyState',
canGoBack: false,
}),
);
expect(postMessage).toHaveBeenNthCalledWith(2,
JSON.stringify({
type: 'genarrative.mobile.historyState',
canGoBack: true,
}),
);
expect(postMessage).toHaveBeenNthCalledWith(3,
JSON.stringify({
type: 'genarrative.mobile.historyState',
canGoBack: true,
}),
);
expect(postMessage).toHaveBeenNthCalledWith(4,
JSON.stringify({
type: 'genarrative.mobile.historyState',
canGoBack: false,
}),
);
});
test('重复注入时回放当前 H5 路由栈状态', () => {
const postMessage = vi.fn();
window.ReactNativeWebView = {
postMessage,
};
window.eval(TRACK_MOBILE_WEBVIEW_HISTORY_SCRIPT);
window.history.pushState({ route: 'detail' }, '', '/works/detail');
window.eval(TRACK_MOBILE_WEBVIEW_HISTORY_SCRIPT);
expect(postMessage).toHaveBeenNthCalledWith(1,
JSON.stringify({
type: 'genarrative.mobile.historyState',
canGoBack: false,
}),
);
expect(postMessage).toHaveBeenNthCalledWith(2,
JSON.stringify({
type: 'genarrative.mobile.historyState',
canGoBack: true,
}),
);
expect(postMessage).toHaveBeenNthCalledWith(3,
JSON.stringify({
type: 'genarrative.mobile.historyState',
canGoBack: true,
}),
);
});
test('组合注入脚本同时保留下载拦截和路由状态追踪', () => {
const postMessage = vi.fn();
window.ReactNativeWebView = {
postMessage,
};
window.eval(MOBILE_WEBVIEW_BEFORE_CONTENT_SCRIPT);
const opened = window.open('blob:https://app.genarrative.world/file-id');
window.history.pushState(null, '', '/creation/puzzle');
expect(opened).toBeNull();
expect(postMessage).toHaveBeenCalledWith(
JSON.stringify({
type: 'genarrative.mobile.historyState',
canGoBack: true,
}),
);
});
});
describe('shouldBlockMobileWebViewDownloadUrl', () => {
test('识别不能进入移动壳 WebView 的下载协议', () => {
expect(
shouldBlockMobileWebViewDownloadUrl(
'blob:https://app.genarrative.world/file-id',
),
).toBe(true);
expect(shouldBlockMobileWebViewDownloadUrl('data:text/plain,hello')).toBe(
true,
);
expect(shouldBlockMobileWebViewDownloadUrl('file:///tmp/export.png')).toBe(
true,
);
expect(
shouldBlockMobileWebViewDownloadUrl(
'filesystem:https://app.genarrative.world/temporary/export.png',
),
).toBe(true);
});
test('放行普通网页和系统外链协议', () => {
expect(
shouldBlockMobileWebViewDownloadUrl(
'https://app.genarrative.world/works/detail?work=PZ-1',
),
).toBe(false);
expect(shouldBlockMobileWebViewDownloadUrl('/creation/puzzle')).toBe(false);
expect(shouldBlockMobileWebViewDownloadUrl('mailto:hi@example.com')).toBe(
false,
);
expect(shouldBlockMobileWebViewDownloadUrl('tel:+12345678')).toBe(false);
});
});
describe('shouldBlockMobileWebViewNavigationRequest', () => {
test('在 WebView 导航前拦截下载协议', () => {
expect(
shouldBlockMobileWebViewNavigationRequest({
url: 'blob:https://app.genarrative.world/file-id',
}),
).toBe(true);
expect(
shouldBlockMobileWebViewNavigationRequest({
url: 'https://app.genarrative.world/creation/puzzle',
}),
).toBe(false);
});
});