app scaffold

This commit is contained in:
Guangfei.Zhao
2026-08-17 15:29:55 +08:00
commit 681688dfae
301 changed files with 18414 additions and 0 deletions
@@ -0,0 +1,163 @@
// core_webview 的高价值断言——这几条全是安全相关,回归了不会有人察觉:
// 1. 白名单不能被 f6.example.com.evil.com 绕过。
// 2. 非白名单页面调 bridge 必须静默丢弃,连错误都不回(不给探测者反馈)。
// 3. 畸形 JSON 不能让 App 崩。
// 4. 未知 method 必须明确回 UNSUPPORTED_METHOD,不能静默(否则 H5 侧 Promise 永远 pending)。
// 5. 登出清 Cookie,切店不清。
import 'dart:convert';
import 'package:core_foundation/core_foundation.dart';
import 'package:core_webview/core_webview.dart';
import 'package:flutter_test/flutter_test.dart';
class _FakeSurface implements H5Surface {
bool stopped = false;
bool cleared = false;
@override
Future<void> stop() async => stopped = true;
@override
Future<void> clearBrowsingData() async => cleared = true;
}
void main() {
group('UrlGuard', () {
const UrlGuard guard = UrlGuard(<String>{'example.com'});
test('放行白名单域名及其子域', () {
expect(guard.isAllowed(Uri.parse('https://example.com/a')), isTrue);
expect(guard.isAllowed(Uri.parse('https://f6.example.com/a')), isTrue);
expect(guard.isAllowed(Uri.parse('https://F6.EXAMPLE.COM/a')), isTrue);
});
test('挡住后缀伪装——这是白名单实现最经典的一个洞', () {
// 用 contains 实现的话这一条会通过。
expect(guard.isAllowed(Uri.parse('https://f6.example.com.evil.com/a')), isFalse);
expect(guard.isAllowed(Uri.parse('https://notexample.com/a')), isFalse);
});
test('只允许 HTTPSdev 也不放开', () {
expect(guard.isAllowed(Uri.parse('http://example.com/a')), isFalse);
expect(guard.isAllowedUrl('about:blank'), isFalse);
expect(guard.isAllowedUrl(null), isFalse);
});
});
group('BridgeDispatcher', () {
late List<String> evaluated;
late String currentUrl;
BridgeDispatcher build(Map<String, BridgeHandler> handlers) => BridgeDispatcher(
urlGuard: const UrlGuard(<String>{'example.com'}),
handlers: handlers,
currentUrl: () async => currentUrl,
evaluateJavaScript: (String js) async => evaluated.add(js),
log: (String message, {Object? error}) {},
);
setUp(() {
evaluated = <String>[];
currentUrl = 'https://f6.example.com/quote';
});
test('非白名单页面的调用静默丢弃,不回包', () async {
currentUrl = 'https://evil.com/x';
await build(<String, BridgeHandler>{
'scan': (Map<String, dynamic> _) async => 'never',
}).handle('{"id":"1","method":"scan"}');
expect(evaluated, isEmpty, reason: '回任何东西都是在给探测者反馈');
});
test('畸形 JSON 不崩也不回包', () async {
await build(const <String, BridgeHandler>{}).handle('{not json');
expect(evaluated, isEmpty);
});
test('未知 method 明确回 UNSUPPORTED_METHOD,不静默', () async {
await build(const <String, BridgeHandler>{}).handle('{"id":"1","method":"teleport"}');
expect(evaluated, hasLength(1));
final Map<String, dynamic> resp = _decodeReply(evaluated.single);
expect(resp['ok'], isFalse);
expect((resp['error']! as Map<String, dynamic>)['code'], 'UNSUPPORTED_METHOD');
});
test('成功调用原样回传 H5 生成的 id', () async {
await build(<String, BridgeHandler>{
'getStoreContext': (Map<String, dynamic> _) async => <String, dynamic>{'storeId': 7},
}).handle('{"id":"c8f1","method":"getStoreContext"}');
final Map<String, dynamic> resp = _decodeReply(evaluated.single);
expect(resp['id'], 'c8f1');
expect(resp['ok'], isTrue);
expect(resp['data'], <String, dynamic>{'storeId': 7});
});
test('AppException 转成稳定的 bridgeCode,不透传原始错误', () async {
await build(<String, BridgeHandler>{
'scan': (Map<String, dynamic> _) async =>
throw const NativeException(NativeErrorCode.permissionDenied, '未授予相机权限'),
}).handle('{"id":"1","method":"scan"}');
final Map<String, dynamic> error =
_decodeReply(evaluated.single)['error']! as Map<String, dynamic>;
expect(error['code'], 'PERMISSION_DENIED');
expect(error['message'], '未授予相机权限');
});
test('非 AppException 归一化成 INTERNAL_ERROR,不泄露技术细节', () async {
await build(<String, BridgeHandler>{
'scan': (Map<String, dynamic> _) async => throw StateError('null check on FooBar'),
}).handle('{"id":"1","method":"scan"}');
final Map<String, dynamic> error =
_decodeReply(evaluated.single)['error']! as Map<String, dynamic>;
expect(error['code'], 'INTERNAL_ERROR');
expect(error['message'], isNot(contains('FooBar')));
});
});
group('WebViewSession', () {
test('切店:停页面但不清 Cookie——用户还是同一个人', () async {
bool cookiesCleared = false;
final WebViewSession session = WebViewSession(
clearCookies: () async => cookiesCleared = true,
);
final _FakeSurface surface = _FakeSurface();
session.register(surface);
await session.onStoreChanged();
expect(surface.stopped, isTrue);
expect(cookiesCleared, isFalse);
expect(surface.cleared, isFalse);
expect(session.openCount, 0);
});
test('登出:必须清 Cookie——门店共用设备上会串号', () async {
bool cookiesCleared = false;
final WebViewSession session = WebViewSession(
clearCookies: () async => cookiesCleared = true,
);
final _FakeSurface surface = _FakeSurface();
session.register(surface);
await session.onSessionEnded();
expect(surface.stopped, isTrue);
expect(cookiesCleared, isTrue);
expect(surface.cleared, isTrue);
expect(session.openCount, 0);
});
});
}
/// 从 `window.__contiBridgeCallback({...});` 里把 JSON 抠出来。
Map<String, dynamic> _decodeReply(String js) {
final int start = js.indexOf('({') + 1;
final int end = js.lastIndexOf('})') + 1;
return jsonDecode(js.substring(start, end)) as Map<String, dynamic>;
}