fix:参考金值,研听,研值工程通用通用工程公共能力,重点对比网络请求、登录认证、状态管理、本地存储、WebView、推送、版本更新、一键登录、主题/UI 等模块,确定后续统一整改方案

This commit is contained in:
jingyun committed 2026-09-10 09:52:36 +08:00
1 parent 6997a65c96
commit d364c6d908
46 files changed
+2060 -395

No files matched your search

@@ -0,0 +1,74 @@
import 'dart:typed_data';
import 'package:dio/dio.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:jinzhi_flutter_app/common/network/api_exception.dart';
import 'package:jinzhi_flutter_app/common/network/kratos_error_interceptor.dart';
import 'package:jinzhi_flutter_app/models/pb/errors/errors.pb.dart' as errors;
void main() {
test('classifies Kratos invalid token and preserves the raw body', () async {
final body = errors.Status(
code: 401,
reason: 'INVALID_TOKEN',
message: 'expired',
).writeToBuffer();
final dio = Dio()
..httpClientAdapter = _ErrorAdapter(body, 401)
..interceptors.add(KratosErrorInterceptor());
try {
await dio.get<List<int>>(
'/me',
options: Options(responseType: ResponseType.bytes),
);
fail('request should fail');
} on DioException catch (error) {
final exception = ApiException.from(error);
expect(exception.kind, ApiExceptionKind.backend);
expect(exception.reason, 'INVALID_TOKEN');
expect(exception.isUnauthorized, isTrue);
expect(error.response!.data, body);
}
});
test('classifies transport, retryable server errors, and cancellation', () {
final request = RequestOptions(path: '/test');
expect(
ApiException.fromDio(DioException(requestOptions: request)).kind,
ApiExceptionKind.transport,
);
expect(
ApiException.fromDio(
DioException(
requestOptions: request,
response: Response<void>(requestOptions: request, statusCode: 503),
),
).isRetryable,
isTrue,
);
expect(
ApiException.fromDio(
DioException(requestOptions: request, type: DioExceptionType.cancel),
).kind,
ApiExceptionKind.cancelled,
);
});
}
class _ErrorAdapter implements HttpClientAdapter {
_ErrorAdapter(this.body, this.status);
final List<int> body;
final int status;
@override
Future<ResponseBody> fetch(
RequestOptions options,
Stream<Uint8List>? requestStream,
Future<void>? cancelFuture,
) async => ResponseBody.fromBytes(body, status);
@override
void close({bool force = false}) {}
}
@@ -0,0 +1,74 @@
import 'dart:typed_data';
import 'package:dio/dio.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:jinzhi_flutter_app/common/network/auth_interceptor.dart';
import 'package:jinzhi_flutter_app/common/storage/preferences_storage.dart';
import 'package:jinzhi_flutter_app/common/storage/secure_identity_storage.dart';
import 'package:shared_preferences/shared_preferences.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
test('external absolute URLs lose all backend identity headers', () async {
SharedPreferences.setMockInitialValues({});
final preferences = await SharedPreferences.getInstance();
final session = AuthSessionStorage(
SecureIdentityStorage(
PreferencesStorage(preferences),
secureStorage: _MemorySecureStorage(),
),
);
final dio = Dio(BaseOptions(baseUrl: 'https://api.example.com'))
..interceptors.add(
AuthInterceptor(
session,
backendOrigin: Uri.parse('https://api.example.com'),
),
)
..httpClientAdapter = _CaptureAdapter();
final response = await dio.get<void>(
'https://cdn.example.net/ad.png',
options: Options(
headers: {
'Authorization': 'Bearer leaked',
'X-Device-Id': 'device',
'User-iOS-IDFA': 'idfa',
'User-DeviceToken': 'push',
},
),
);
final headers = response.requestOptions.headers.keys
.map((key) => key.toLowerCase())
.toSet();
expect(headers, isNot(contains('authorization')));
expect(headers, isNot(contains('x-device-id')));
expect(headers, isNot(contains('user-ios-idfa')));
expect(headers, isNot(contains('user-devicetoken')));
});
}
class _MemorySecureStorage implements SecureKeyValueStorage {
final Map<String, String> values = {};
@override
Future<void> delete(String key) async => values.remove(key);
@override
Future<String?> read(String key) async => values[key];
@override
Future<void> write(String key, String value) async => values[key] = value;
}
class _CaptureAdapter implements HttpClientAdapter {
@override
Future<ResponseBody> fetch(
RequestOptions options,
Stream<Uint8List>? requestStream,
Future<void>? cancelFuture,
) async => ResponseBody.fromString('', 204);
@override
void close({bool force = false}) {}
}
@@ -0,0 +1,55 @@
import 'dart:typed_data';
import 'package:dio/dio.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:jinzhi_flutter_app/common/push/push_api_client.dart';
import 'package:jinzhi_flutter_app/models/pb/jinzhi/v1/push.pb.dart' as pb;
void main() {
test(
'Retrofit push client uses generated protobuf in both directions',
() async {
final adapter = _PushAdapter();
final client = PushApiClient(
Dio(BaseOptions(responseType: ResponseType.bytes))
..httpClientAdapter = adapter,
);
final reply = await client.registerDevice(
pb.RegisterPushDeviceRequest(
umengDeviceToken: 'push-token',
platform: pb.Platform.PLATFORM_IOS,
appVersion: '1.1.0',
permissionGranted: true,
),
);
expect(reply.deviceRecordId, 'device-record');
final request = pb.RegisterPushDeviceRequest.fromBuffer(adapter.body!);
expect(request.umengDeviceToken, 'push-token');
expect(request.platform, pb.Platform.PLATFORM_IOS);
},
);
}
class _PushAdapter implements HttpClientAdapter {
List<int>? body;
@override
Future<ResponseBody> fetch(
RequestOptions options,
Stream<Uint8List>? requestStream,
Future<void>? cancelFuture,
) async {
body = (options.data as List<int>).toList();
return ResponseBody.fromBytes(
pb.RegisterPushDeviceReply(
deviceRecordId: 'device-record',
).writeToBuffer(),
200,
);
}
@override
void close({bool force = false}) {}
}
@@ -0,0 +1,110 @@
import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
import 'package:jinzhi_flutter_app/common/router/deep_link_service.dart';
void main() {
setUp(DeepLinkService.resetForTest);
test('keeps FIFO events until the router is ready', () {
DeepLinkService.configureTrustedHttpsHosts({'links.example.com'});
DeepLinkService.handle('jinzhi://market');
DeepLinkService.handleUri(Uri.parse('https://links.example.com/news?id=1'));
expect(DeepLinkService.pendingCount, 2);
final navigated = <String>[];
DeepLinkService.attachRouter((location) {
navigated.add(location);
return true;
});
expect(navigated, ['/market', '/news?id=1']);
expect(DeepLinkService.pendingCount, 0);
});
test('rejects HTTPS links outside the exact trusted host allowlist', () {
DeepLinkService.configureTrustedHttpsHosts({'links.example.com'});
final navigated = <String>[];
DeepLinkService.attachRouter((location) {
navigated.add(location);
return true;
});
DeepLinkService.handleUri(Uri.parse('https://evil.example/news'));
DeepLinkService.handleUri(Uri.parse('https://sub.links.example.com/news'));
DeepLinkService.handleUri(Uri.parse('https://links.example.com:8443/news'));
DeepLinkService.handleUri(Uri.parse('https://links.example.com/news'));
expect(navigated, ['/news']);
expect(DeepLinkService.pendingCount, 0);
});
test('disables HTTPS deep links when no production host is configured', () {
final navigated = <String>[];
DeepLinkService.attachRouter((location) {
navigated.add(location);
return true;
});
DeepLinkService.handleUri(Uri.parse('https://links.example.com/news'));
DeepLinkService.handle('jinzhi://news');
expect(navigated, ['/news']);
});
test('deduplicates event ids even after the first event is consumed', () {
final navigated = <String>[];
DeepLinkService.attachRouter((location) {
navigated.add(location);
return true;
});
final payload = jsonEncode({
'event_id': 'same-event',
'deep_link': 'jinzhi://assets',
});
DeepLinkService.handle(payload);
DeepLinkService.handle(payload);
expect(navigated, ['/assets']);
});
test('uses bounded queue and discards expired events', () {
final old = DateTime.now().subtract(DeepLinkService.expiry * 2);
DeepLinkService.enqueue(
'jinzhi://assets',
source: DeepLinkSource.appLink,
receivedAt: old,
);
for (var index = 0; index < DeepLinkService.maxPending + 5; index++) {
DeepLinkService.enqueue(
'jinzhi://news?event_id=$index',
source: DeepLinkSource.push,
);
}
expect(DeepLinkService.pendingCount, DeepLinkService.maxPending);
final navigated = <String>[];
DeepLinkService.attachRouter((location) {
navigated.add(location);
return true;
});
expect(navigated, hasLength(DeepLinkService.maxPending));
expect(navigated, isNot(contains('/assets')));
});
test('auth guard leaves the head pending until auth changes', () {
var authenticated = false;
final navigated = <String>[];
DeepLinkService.attachRouter((location) {
navigated.add(location);
return true;
}, authGuard: (_) => authenticated);
DeepLinkService.handle('jinzhi://profile');
expect(DeepLinkService.pendingCount, 1);
authenticated = true;
DeepLinkService.notifyAuthChanged();
expect(navigated, ['/profile']);
});
}
@@ -1,6 +1,9 @@
import 'dart:async';
import 'package:flutter_test/flutter_test.dart';
import 'package:jinzhi_flutter_app/common/storage/secure_identity_storage.dart';
import 'package:jinzhi_flutter_app/common/storage/storage_keys.dart';
import 'package:jinzhi_flutter_app/common/storage/preferences_storage.dart';
import 'package:shared_preferences/shared_preferences.dart';
void main() {
@@ -14,7 +17,10 @@ void main() {
});
final preferences = await SharedPreferences.getInstance();
final secure = _MemorySecureStorage();
final storage = SecureIdentityStorage(preferences, secureStorage: secure);
final storage = SecureIdentityStorage(
PreferencesStorage(preferences),
secureStorage: secure,
);
expect(await storage.restoreAccessToken(), 'legacy');
expect(await secure.read('access_token'), 'legacy');
@@ -24,17 +30,109 @@ void main() {
);
test(
'migrates the previous secure jwt key when no current token exists',
'rejects an orphaned Keychain token when the install marker is absent',
() async {
final preferences = await SharedPreferences.getInstance();
final secure = _MemorySecureStorage({'jwt_token': 'secure-legacy'});
final storage = SecureIdentityStorage(preferences, secureStorage: secure);
final storage = SecureIdentityStorage(
PreferencesStorage(preferences),
secureStorage: secure,
);
expect(await storage.restoreAccessToken(), 'secure-legacy');
expect(await secure.read('access_token'), 'secure-legacy');
expect(await storage.restoreAccessToken(), isNull);
expect(await secure.read('access_token'), isNull);
expect(await secure.read('jwt_token'), isNull);
},
);
test('restores a secure token only with the installation marker', () async {
SharedPreferences.setMockInitialValues({storageAuthSessionActive: true});
final preferences = await SharedPreferences.getInstance();
final secure = _MemorySecureStorage({secureAccessToken: 'active'});
final storage = SecureIdentityStorage(
PreferencesStorage(preferences),
secureStorage: secure,
);
expect(await storage.restoreAccessToken(), 'active');
});
test('logout clears session secrets but preserves device identity', () async {
SharedPreferences.setMockInitialValues({storageAuthSessionActive: true});
final preferences = await SharedPreferences.getInstance();
final secure = _MemorySecureStorage({
secureAccessToken: 'access',
secureMallCredential: 'mall',
secureRefreshToken: 'refresh',
secureBiometricCredential: 'biometric',
secureDeviceId: 'device',
});
final storage = SecureIdentityStorage(
PreferencesStorage(preferences),
secureStorage: secure,
);
await storage.clearSessionCredentials();
expect(await secure.read(secureAccessToken), isNull);
expect(await secure.read(secureMallCredential), isNull);
expect(await secure.read(secureRefreshToken), isNull);
expect(await secure.read(secureBiometricCredential), 'biometric');
expect(await secure.read(secureDeviceId), 'device');
await storage.clearAccountCredentials();
expect(await secure.read(secureBiometricCredential), isNull);
expect(await secure.read(secureDeviceId), 'device');
});
test(
'serializes restore with logout so an old token cannot revive',
() async {
SharedPreferences.setMockInitialValues({storageAuthSessionActive: true});
final preferences = await SharedPreferences.getInstance();
final secure = _BlockingReadSecureStorage({
secureAccessToken: 'old-token',
});
final session = AuthSessionStorage(
SecureIdentityStorage(
PreferencesStorage(preferences),
secureStorage: secure,
),
);
final restoring = session.restore();
await secure.readStarted.future;
final clearing = session.clear();
secure.releaseRead.complete();
expect(await restoring, 'old-token');
await clearing;
expect(session.accessToken, isNull);
expect(await secure.read(secureAccessToken), isNull);
},
);
test('serializes restore with login so the new token wins', () async {
SharedPreferences.setMockInitialValues({storageAuthSessionActive: true});
final preferences = await SharedPreferences.getInstance();
final secure = _BlockingReadSecureStorage({secureAccessToken: 'old-token'});
final session = AuthSessionStorage(
SecureIdentityStorage(
PreferencesStorage(preferences),
secureStorage: secure,
),
);
final restoring = session.restore();
await secure.readStarted.future;
final saving = session.save('new-token');
secure.releaseRead.complete();
expect(await restoring, 'old-token');
await saving;
expect(session.accessToken, 'new-token');
expect(await secure.read(secureAccessToken), 'new-token');
});
}
class _MemorySecureStorage implements SecureKeyValueStorage {
@@ -54,3 +152,21 @@ class _MemorySecureStorage implements SecureKeyValueStorage {
_values[key] = value;
}
}
class _BlockingReadSecureStorage extends _MemorySecureStorage {
_BlockingReadSecureStorage(super.initial);
final readStarted = Completer<void>();
final releaseRead = Completer<void>();
var _blocked = false;
@override
Future<String?> read(String key) async {
if (key == secureAccessToken && !_blocked) {
_blocked = true;
readStarted.complete();
await releaseRead.future;
}
return super.read(key);
}
}