231 lines
9.8 KiB
Dart
231 lines
9.8 KiB
Dart
import 'dart:convert';
|
|
import 'dart:io';
|
|
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:revoltvpn/logic/secure_socks_session.dart';
|
|
|
|
void main() {
|
|
test('secure SOCKS session owns the entire inbound surface', () async {
|
|
final session = await SecureSocksSession.create(jsonEncode({
|
|
'inbounds': [
|
|
{
|
|
'tag': 'remote-listener',
|
|
'listen': '0.0.0.0',
|
|
'port': 9999,
|
|
'protocol': 'dokodemo-door',
|
|
},
|
|
{
|
|
'tag': 'old-http',
|
|
'listen': '127.0.0.1',
|
|
'port': 8080,
|
|
'protocol': 'http',
|
|
},
|
|
],
|
|
'outbounds': [
|
|
{'tag': 'proxy', 'protocol': 'freedom'}
|
|
],
|
|
}));
|
|
|
|
final decoded = jsonDecode(session.configJson) as Map<String, dynamic>;
|
|
final inbounds = decoded['inbounds'] as List<dynamic>;
|
|
expect(inbounds, hasLength(1));
|
|
|
|
final inbound = inbounds.single as Map<String, dynamic>;
|
|
expect(inbound['tag'], SecureSocksSession.inboundTag);
|
|
expect(inbound['listen'], '127.0.0.1');
|
|
expect(inbound['protocol'], 'socks');
|
|
expect(inbound['port'], session.port);
|
|
|
|
final settings = inbound['settings'] as Map<String, dynamic>;
|
|
expect(settings['auth'], 'password');
|
|
final users = settings['users'] as List<dynamic>;
|
|
expect(users, hasLength(1));
|
|
final account = users.single as Map<String, dynamic>;
|
|
expect(account['user'], session.username);
|
|
expect(account['pass'], session.password);
|
|
});
|
|
|
|
test('vendored native source keeps full-tunnel and private IPC invariants', () {
|
|
const root =
|
|
'local_packages/flutter_vless_android-1.1.5/android/src/main';
|
|
final service = File(
|
|
'$root/kotlin/com/github/tfox/flutter_vless/xray/service/XrayVPNService.kt',
|
|
).readAsStringSync();
|
|
final core = File(
|
|
'$root/kotlin/com/github/tfox/flutter_vless/xray/core/XrayCoreManager.kt',
|
|
).readAsStringSync();
|
|
final plugin = File(
|
|
'$root/kotlin/com/github/tfox/flutter_vless/FlutterVlessPlugin.kt',
|
|
).readAsStringSync();
|
|
final manifest = File('$root/AndroidManifest.xml').readAsStringSync();
|
|
|
|
expect(service, contains('builder.addRoute("0.0.0.0", 0)'));
|
|
expect(service, contains('builder.addRoute("::", 0)'));
|
|
expect(service, contains('Android refused to establish VPN interface'));
|
|
expect(service, contains('Per-app VPN bypass is disabled in ReVolt'));
|
|
expect(service, contains('Failed to configure VPN DNS'));
|
|
expect(service, contains('reader.forEachLine { _ -> }'));
|
|
|
|
expect(plugin, contains('ContextCompat.RECEIVER_NOT_EXPORTED'));
|
|
expect(core, contains('.setPackage(context.packageName)'));
|
|
expect(core, contains('REVOLT_VPN_SERVICE'));
|
|
expect(core, contains('NotificationManager.IMPORTANCE_LOW'));
|
|
expect(core, contains('deleteEphemeralConfigOnFailure'));
|
|
expect(core, contains('reader.forEachLine { _ -> }'));
|
|
|
|
expect(manifest, contains('android:exported="false"'));
|
|
expect(manifest, contains('android:permission="android.permission.BIND_VPN_SERVICE"'));
|
|
expect(manifest, contains('android.net.VpnService.SUPPORTS_ALWAYS_ON'));
|
|
expect(manifest, contains('android:value="false"'));
|
|
expect(manifest, isNot(contains('android.permission.CHANGE_NETWORK_STATE')));
|
|
expect(manifest, isNot(contains('android.permission.READ_EXTERNAL_STORAGE')));
|
|
});
|
|
|
|
test('native app bridge keeps runtime adoption on private heartbeat only', () {
|
|
final source = File(
|
|
'android/app/src/main/kotlin/com/paladinvpn/app/MainActivity.kt',
|
|
).readAsStringSync();
|
|
final vpnSource = File('lib/logic/vpn_connection.dart').readAsStringSync();
|
|
final powerSource = File('lib/logic/power_settings.dart').readAsStringSync();
|
|
|
|
expect(source, isNot(contains('openVpnSettings')));
|
|
expect(source, isNot(contains('ACTION_VPN_SETTINGS')));
|
|
expect(source, isNot(contains('FROM_DISCONNECT_BTN')));
|
|
expect(source, isNot(contains('isVpnRuntimeAlive')));
|
|
expect(source, isNot(contains('isVpnServiceProcessAlive')));
|
|
expect(source, contains('REVOLT_VPN_SERVICE'));
|
|
|
|
expect(vpnSource, contains('if (_connectEpoch == 0) _adoptedRunningRuntime = true;'));
|
|
expect(vpnSource, isNot(contains('PowerSettings.runtimeState')));
|
|
expect(vpnSource, isNot(contains('_adoptRunningRuntime')));
|
|
expect(powerSource, isNot(contains('runtimeState')));
|
|
expect(powerSource, isNot(contains('VpnRuntimeState')));
|
|
});
|
|
|
|
test('configured AdMob bypass contract remains intact', () {
|
|
final source = File('lib/logic/hivemind_service.dart').readAsStringSync();
|
|
|
|
expect(source, contains('await _runConfiguredBypass(deviceId, nonce);'));
|
|
expect(
|
|
source,
|
|
contains('/admob/callback?signature=test&key_id=test&custom_data='),
|
|
);
|
|
});
|
|
|
|
test('local SOCKS diagnostics cannot fall back to unauthenticated legacy mode', () {
|
|
final source = File('lib/logic/local_socks_tester.dart').readAsStringSync();
|
|
|
|
expect(source, contains('required int port'));
|
|
expect(source, contains('required String username'));
|
|
expect(source, contains('required String password'));
|
|
expect(source, contains('const <int>[0x05, 0x01, 0x02]'));
|
|
expect(source, isNot(contains('const <int>[0x05, 0x01, 0x00]')));
|
|
expect(source, isNot(contains('int port = 10807')));
|
|
expect(source, isNot(contains('paladinvpn.duckdns.org')));
|
|
expect(source, contains("String targetHost = 'example.com'"));
|
|
expect(source, contains('const totalTimeout = Duration(seconds: 8);'));
|
|
});
|
|
|
|
test('hivemind rejects missing nonce and stale session responses', () {
|
|
final source = File('lib/logic/hivemind_service.dart').readAsStringSync();
|
|
|
|
expect(source, contains('_expectedNonce != nonce ||'));
|
|
expect(source, contains('serverNonce is! String ||'));
|
|
expect(
|
|
source,
|
|
contains(
|
|
'final session = await _fetchActiveSession(deviceId, nonce);\n'
|
|
' _throwIfCancelled(callId);',
|
|
),
|
|
);
|
|
});
|
|
|
|
test('hivemind validates and pins session fields before constructing VLESS', () {
|
|
final source = File('lib/logic/hivemind_service.dart').readAsStringSync();
|
|
|
|
expect(source, contains('_uuidPattern'));
|
|
expect(source, contains('_maxControlResponseBytes = 256 * 1024'));
|
|
expect(source, contains('controlGet('));
|
|
expect(source, contains('_validatedPublicIp(AppConfig.serverIp'));
|
|
expect(source, contains('const port = 443;'));
|
|
expect(source, contains("_requiredString(json, 'reality_pbk', maxLength: 128)"));
|
|
expect(source, contains('_validatedHost('));
|
|
expect(
|
|
source,
|
|
contains("final pinnedHost = _validatedPublicIp(AppConfig.serverIp, 'serverIp');"),
|
|
);
|
|
expect(source, contains('advertisedHost.trim() != pinnedHost'));
|
|
expect(
|
|
source,
|
|
isNot(contains("_stringOr(json['vless_ip'], AppConfig.serverIp)")),
|
|
);
|
|
expect(source, contains('return Uri('));
|
|
expect(source, contains('queryParameters: <String, String>{'));
|
|
expect(source, isNot(contains("return 'vless://\$uuid@\$host:\$port'")));
|
|
});
|
|
|
|
test('VPN connect work cannot outlive its generation', () {
|
|
final source = File('lib/logic/vpn_connection.dart').readAsStringSync();
|
|
|
|
expect(source, contains('int _connectEpoch = 0;'));
|
|
expect(source, contains('final connectEpoch = ++_connectEpoch;'));
|
|
expect(source, contains('epoch == _connectEpoch && !_userDisconnecting'));
|
|
expect(source, contains('_connectEpoch++;\n _suppressNativeConnect = true;'));
|
|
expect(source, contains('if (_suppressNativeConnect || _userDisconnecting) return;'));
|
|
expect(source, isNot(contains('bool _cancelled = false;')));
|
|
});
|
|
|
|
test('native recovery is fail-closed without hot process restart loops', () {
|
|
const root =
|
|
'local_packages/flutter_vless_android-1.1.5/android/src/main/kotlin/com/github/tfox/flutter_vless';
|
|
final service =
|
|
File('$root/xray/service/XrayVPNService.kt').readAsStringSync();
|
|
final core = File('$root/xray/core/XrayCoreManager.kt').readAsStringSync();
|
|
|
|
expect(service, contains('scheduleTun2socksRecovery'));
|
|
expect(service, contains('tun2socksRecoveryAttempt'));
|
|
expect(service, contains('coerceAtMost(30_000L)'));
|
|
expect(service, contains('recoveringTun2socks = false'));
|
|
expect(service, contains('tun2socksRecoveryAttempt = 0'));
|
|
expect(service, contains('XrayCoreManager.markRuntimeConnecting(this, config)'));
|
|
expect(service, isNot(contains('Thread.sleep(350)')));
|
|
expect(service, isNot(contains('Thread.sleep(600)')));
|
|
expect(
|
|
service,
|
|
isNot(contains('attempt = 0\n Thread.sleep(3000)')),
|
|
);
|
|
|
|
expect(core, contains('context.handleXrayCoreExit(config)'));
|
|
expect(core, contains('AppConfigs.RUNTIME_READY = false'));
|
|
});
|
|
|
|
test('data disclosure only persists an explicit acknowledged action', () {
|
|
final source =
|
|
File('lib/components/data_disclosure_dialog.dart').readAsStringSync();
|
|
|
|
expect(source, contains('final accepted = await showDialog<bool>('));
|
|
expect(source, contains('if (accepted != true) return;'));
|
|
expect(source, contains('final saved = await prefs.setBool(_key, true);'));
|
|
expect(source, contains('if (!saved)'));
|
|
expect(source, contains('Navigator.of(context).pop(true)'));
|
|
expect(source, isNot(contains('prefs.setBool(_key, true);\n Navigator')));
|
|
});
|
|
|
|
test('unsupported server picker is not wired into app state', () {
|
|
final mainSource = File('lib/main.dart').readAsStringSync();
|
|
final statusBar = File('lib/components/status_bar.dart').readAsStringSync();
|
|
|
|
expect(mainSource, isNot(contains('ServerList')));
|
|
expect(statusBar, isNot(contains('SelectionScreen')));
|
|
});
|
|
|
|
test('session timer rejects stale session work by generation', () {
|
|
final source = File('lib/logic/session_timer.dart').readAsStringSync();
|
|
|
|
expect(source, contains('_sessionEpoch'));
|
|
expect(source, contains('Stopwatch'));
|
|
expect(source, contains("_doDisconnect('VPN entered error state')"));
|
|
expect(source, isNot(contains('_stopForVpnFailure')));
|
|
});
|
|
}
|