app scaffold
This commit is contained in:
@@ -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('只允许 HTTPS,dev 也不放开', () {
|
||||
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>;
|
||||
}
|
||||
Reference in New Issue
Block a user