b6b1e21211
将移动 WebView 禁止下载协议提升到共享 HostBridge 契约 让移动壳导航拦截和注入脚本复用共享协议清单 增加移动壳门禁和测试防止协议清单回退到本地字面量 补充宿主壳方案文档和共享决策记录
270 lines
8.1 KiB
TypeScript
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);
|
|
});
|
|
});
|