补齐移动壳文件载荷单测

移动壳文件载荷 helper 增加独立单测覆盖

原生壳结构门禁登记移动文件载荷测试

宿主壳文档和共享决策同步测试边界
This commit is contained in:
2026-06-20 07:23:42 +08:00
parent 9188a003ed
commit b0421d187f
5 changed files with 214 additions and 4 deletions
@@ -0,0 +1,208 @@
import { describe, expect, test } from 'vitest';
import { HOST_BRIDGE_IMPORT_IMAGE_MAX_BYTES } from '../../../../packages/shared/src/contracts/hostBridge';
import {
assertImportedFileSizeWithinLimit,
base64DecodedByteLength,
ensureAudioBytesMatchMimeType,
ensureImageBytesMatchMimeType,
imagePickerResultToImportPayload,
normalizedBase64Data,
normalizeExportedAudioFileName,
normalizeExportedImageFileName,
normalizeImportedAudioMimeType,
normalizeImportedDocumentMimeType,
normalizeImportedTextMimeType,
utf8ByteLength,
} from './filePayloads';
function encodeBytes(bytes: readonly number[]) {
return Buffer.from(bytes).toString('base64');
}
const PNG_BASE64 = encodeBytes([
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 0,
]);
const JPEG_BASE64 = encodeBytes([0xff, 0xd8, 0xff, 0xe0, 0, 0, 0]);
const WEBP_BASE64 = Buffer.from('RIFF\x04\x00\x00\x00WEBP', 'binary').toString(
'base64',
);
const MP3_BASE64 = Buffer.from('ID3\x04\x00\x00\x00\x00\x00\x10', 'binary').toString(
'base64',
);
const WAV_BASE64 = Buffer.from('RIFF\x04\x00\x00\x00WAVE', 'binary').toString(
'base64',
);
const WEBM_BASE64 = encodeBytes([0x1a, 0x45, 0xdf, 0xa3, 0x01, 0x00]);
describe('mobile file payload helpers', () => {
test('base64 and UTF-8 byte boundaries are deterministic', () => {
expect(utf8ByteLength('泥巴AI')).toBe(Buffer.byteLength('泥巴AI'));
expect(normalizedBase64Data(` ${PNG_BASE64} `)).toBe(PNG_BASE64);
expect(normalizedBase64Data('not base64')).toBeNull();
expect(base64DecodedByteLength(PNG_BASE64)).toBe(12);
});
test('normalizes imported text and document MIME types from MIME or extension', () => {
expect(normalizeImportedTextMimeType('TEXT/MARKDOWN', 'story.md')).toBe(
'text/markdown',
);
expect(normalizeImportedTextMimeType(undefined, 'world.json')).toBe(
'application/json',
);
expect(normalizeImportedDocumentMimeType(undefined, 'brief.docx')).toBe(
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
);
expect(normalizeImportedDocumentMimeType('application/pdf', 'brief.pdf')).toBeNull();
});
test('normalizes audio MIME types from MIME or extension', () => {
expect(normalizeImportedAudioMimeType('AUDIO/WEBM', 'voice.bin')).toBe(
'audio/webm',
);
expect(normalizeImportedAudioMimeType(undefined, 'voice.mp3')).toBe(
'audio/mpeg',
);
expect(normalizeImportedAudioMimeType(undefined, 'voice.flac')).toBeNull();
});
test('normalizes exported image and audio file extensions', () => {
expect(normalizeExportedImageFileName('卡片', 'image/jpeg')).toBe('卡片.jpg');
expect(normalizeExportedImageFileName('卡片.webp', 'image/webp')).toBe(
'卡片.webp',
);
expect(normalizeExportedAudioFileName('声浪', 'audio/mpeg')).toBe('声浪.mp3');
expect(normalizeExportedAudioFileName('声浪.webm', 'audio/webm')).toBe(
'声浪.webm',
);
});
test('validates image and audio bytes against declared MIME', () => {
expect(() => ensureImageBytesMatchMimeType(PNG_BASE64, 'image/png')).not.toThrow();
expect(() => ensureImageBytesMatchMimeType(JPEG_BASE64, 'image/jpeg')).not.toThrow();
expect(() => ensureImageBytesMatchMimeType(WEBP_BASE64, 'image/webp')).not.toThrow();
expect(() => ensureImageBytesMatchMimeType(JPEG_BASE64, 'image/png')).toThrow(
'image bytes do not match MIME',
);
expect(() => ensureAudioBytesMatchMimeType(MP3_BASE64, 'audio/mpeg')).not.toThrow();
expect(() => ensureAudioBytesMatchMimeType(WAV_BASE64, 'audio/wav')).not.toThrow();
expect(() => ensureAudioBytesMatchMimeType(WEBM_BASE64, 'audio/webm')).not.toThrow();
expect(() => ensureAudioBytesMatchMimeType(MP3_BASE64, 'audio/wav')).toThrow(
'audio bytes do not match MIME',
);
});
test('checks imported file size from picker metadata before file fallback', () => {
expect(() =>
assertImportedFileSizeWithinLimit(12, { size: undefined }, 20, 'too large'),
).not.toThrow();
expect(() =>
assertImportedFileSizeWithinLimit(undefined, { size: 12 }, 20, 'too large'),
).not.toThrow();
expect(() =>
assertImportedFileSizeWithinLimit(21, { size: 12 }, 20, 'too large'),
).toThrow('too large');
expect(() =>
assertImportedFileSizeWithinLimit(undefined, { size: undefined }, 20, 'too large'),
).toThrow('too large');
});
test('maps ImagePicker results to HostBridge import payloads', () => {
const payload = imagePickerResultToImportPayload(
{
canceled: false,
assets: [
{
uri: 'file:///private/mobile/参考图',
width: 120,
height: 80,
type: 'image',
fileName: '参考图',
fileSize: 12,
base64: PNG_BASE64,
mimeType: 'image/png',
},
],
},
'selected',
);
expect(payload).toEqual({
action: 'selected',
fileName: '参考图.png',
base64Data: PNG_BASE64,
mimeType: 'image/png',
bytes: 12,
});
});
test('rejects cancelled, invalid and oversized ImagePicker results', () => {
expect(() =>
imagePickerResultToImportPayload({ canceled: true, assets: null }, 'selected'),
).toThrow('file import cancelled');
expect(() =>
imagePickerResultToImportPayload(
{
canceled: false,
assets: [
{
uri: 'file:///private/mobile/参考图.gif',
width: 120,
height: 80,
type: 'image',
fileName: '参考图.gif',
fileSize: 5,
base64: PNG_BASE64,
mimeType: 'image/gif',
},
],
},
'selected',
),
).toThrow('mimeType must be an allowed image type');
expect(() =>
imagePickerResultToImportPayload(
{
canceled: false,
assets: [
{
uri: 'file:///private/mobile/参考图.png',
width: 120,
height: 80,
type: 'image',
fileName: '参考图.png',
fileSize: HOST_BRIDGE_IMPORT_IMAGE_MAX_BYTES + 1,
base64: PNG_BASE64,
mimeType: 'image/png',
},
],
},
'selected',
),
).toThrow('image exceeds file import size limit');
expect(() =>
imagePickerResultToImportPayload(
{
canceled: false,
assets: [
{
uri: 'file:///private/mobile/参考图.png',
width: 120,
height: 80,
type: 'image',
fileName: '参考图.png',
fileSize: 7,
base64: JPEG_BASE64,
mimeType: 'image/png',
},
],
},
'selected',
),
).toThrow('image bytes do not match MIME');
});
});
@@ -40,6 +40,7 @@
- 2026-06-19 桌面壳窗口标题桥接边界:Tauri `app.setTitle` 的 payload 校验、非空 / 控制字符拒绝、80 字符截断和主窗口 `set_title` 调用统一收口在 `apps/desktop-shell/src-tauri/src/host_bridge/title.rs``dispatch.rs` 只负责委托 `set_desktop_host_bridge_window_title(...)`。桌面壳配置检查和根级结构门禁会覆盖 `title.rs` 文件清单、共享标题长度镜像和 dispatch 委托关系。
- 2026-06-19 桌面壳文件桥接执行边界:Tauri `file.exportText` / `file.importText` / `file.importDocument` / `file.exportImage` / `file.importImage` / `file.importAudio` / `file.exportAudio` 的系统文件对话框过滤器、用户取消语义、路径转换、异步读写编排和 HostBridge 响应统一收口在 `apps/desktop-shell/src-tauri/src/host_bridge/files.rs`;MIME、大小、base64、文件名清洗、本地副本读写和 HostBridge payload 组装统一收口在 `apps/desktop-shell/src-tauri/src/host_bridge/file_payloads.rs``dispatch.rs` 只负责按 method 委托 `export_desktop_host_bridge_*_file(...)` / `import_desktop_host_bridge_*_file(...)`。桌面壳配置检查会拒绝分发层直接调用 `.dialog()``blocking_save_file` / `blocking_pick_file`、文件 payload helper 或落盘 helper,避免文件访问边界重新散落。
- 2026-06-20 移动壳文件桥接载荷边界:Expo `file.exportText` / `file.importText` / `file.importDocument` / `file.exportImage` / `file.importImage` / `file.captureImage` / `file.importAudio` / `file.exportAudio` 的 DocumentPicker、ImagePicker、File、Sharing 系统交互、用户取消语义、缓存读写编排和 HostBridge 响应包装统一收口在 `apps/mobile-shell/src/host-bridge/files.ts`;MIME、大小、base64、文件名清洗、图片 / 音频 bytes 匹配和 picker 结果到 HostBridge payload 的组装统一收口在 `apps/mobile-shell/src/host-bridge/filePayloads.ts`。移动壳单端配置检查和根级 `npm run check:native-shells` 会把 `filePayloads.ts` 纳入结构清单与 HostBridge 源码扫描,避免文件载荷边界重新散落到分发层或 shell 层。
- 2026-06-20 移动文件载荷单测边界:`apps/mobile-shell/src/host-bridge/filePayloads.test.ts` 直接覆盖移动壳文件载荷 helper 的 base64、UTF-8 byte、MIME / 扩展名归一、图片 / 音频 bytes 匹配、导出文件名补扩展、导入大小门禁和 ImagePicker payload 转换;根级 `npm run check:native-shells` 会把该测试文件列入移动桥接层结构清单,防止后续只靠完整 HostBridge bridge 流程间接覆盖文件安全边界。
- 2026-06-20 桌面本地通知契约镜像:Tauri `notification.showLocal` 的 title / body 归一化、长度上限和成功结果 action 必须镜像共享 HostBridge 契约;Rust 侧常量使用 `HOST_BRIDGE_LOCAL_NOTIFICATION_TITLE_MAX_LENGTH``HOST_BRIDGE_LOCAL_NOTIFICATION_BODY_MAX_LENGTH``HOST_BRIDGE_LOCAL_NOTIFICATION_DELIVERED_TO_SYSTEM_ACTION` 命名,桌面单端配置检查会与 `packages/shared/src/contracts/hostBridge.ts` 比对数值并反查成功结果由该 action 常量组装,避免通知 payload 边界变成桌面壳本地规则。
- 2026-06-19 桌面壳外链打开 helper 共用:Tauri WebView 外域拦截和 HostBridge `app.openExternalUrl` 都必须复用 `open_normalized_desktop_external_url` 执行系统外链打开动作;HostBridge 分支仍先用 `normalize_external_url` 保留 payload 错误语义并把 opener 错误回传给 H5WebView 拦截保持 best-effort 静默处理。桌面壳配置检查会拒绝 `dispatch.rs` 直接调用 `app.opener().open_url` 绕过该 helper,避免两条离壳路径漂移。
- 2026-06-20 H5 原生导航预校验:`navigateHostNativePage()``native_app` 下发送 `navigation.openNativePage` 前必须先拒绝空值、控制字符、协议相对 URL、外域绝对 URL 和非 `http:` / `https:` 协议目标;同源绝对 URL、`/path` 和保留给桌面壳兼容的相对 route 继续交给 Expo / Tauri 壳二次归一并补写宿主上下文。微信小程序分支仍按小程序页面 URL 语义走 `wx.miniProgram.navigateTo`,不套原生 App 同源 H5 预校验。根级 `npm run check:native-shells` 会反查 H5 facade 仍使用 `normalizeNativeAppPageUrl(...)` 且发送归一后的 URL,避免明显不安全目标触达原生壳。
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
View File
@@ -256,6 +256,7 @@ const expectedMobileHostBridgeFiles = [
'capabilities.ts',
'clipboard.ts',
'dispatch.ts',
'filePayloads.test.ts',
'filePayloads.ts',
'files.ts',
'haptics.ts',