Add proxy routing and sing-box support
This commit is contained in:
+130
@@ -0,0 +1,130 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_singbox_proxy/flutter_singbox_proxy.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:weblibre/features/geckoview/features/tabs/data/models/container_data.dart';
|
||||
import 'package:weblibre/features/proxy/data/proxy_connection.dart';
|
||||
import 'package:weblibre/features/proxy/domain/providers/proxy_connection_options.dart';
|
||||
import 'package:weblibre/features/proxy/domain/repositories/singbox_proxy_runtime.dart';
|
||||
import 'package:weblibre/features/proxy/presentation/controllers/ensure_proxy_started.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets(
|
||||
'prompts and retries sing-box start when runtime provider is in error',
|
||||
(tester) async {
|
||||
final runtimeRepository = _ErrorRuntimeRepository();
|
||||
final container = ContainerData(
|
||||
id: 'container-1',
|
||||
color: Colors.blue,
|
||||
orderKey: 'a',
|
||||
metadata: ContainerMetadata.withDefaults(
|
||||
proxyConnectionId: const SingboxProxyConnectionId('profile-1'),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
singboxProxyRuntimeRepositoryProvider.overrideWith(
|
||||
() => runtimeRepository,
|
||||
),
|
||||
proxyConnectionOptionsProvider.overrideWith(
|
||||
(ref) => [
|
||||
ProxyConnectionOption(
|
||||
id: const SingboxProxyConnectionId('profile-1'),
|
||||
title: 'Mullvad',
|
||||
subtitle: 'SOCKS',
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
child: MaterialApp(home: _EnsureProxyHarness(container: container)),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.text('Open container'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Start Proxy Connection?'), findsOneWidget);
|
||||
expect(
|
||||
find.textContaining('This container uses Mullvad'),
|
||||
findsOneWidget,
|
||||
);
|
||||
|
||||
await tester.tap(find.text('Start'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(runtimeRepository.startedProfileIds, ['profile-1']);
|
||||
expect(find.text('result:true'), findsOneWidget);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
class _EnsureProxyHarness extends ConsumerStatefulWidget {
|
||||
final ContainerData container;
|
||||
|
||||
const _EnsureProxyHarness({required this.container});
|
||||
|
||||
@override
|
||||
ConsumerState<_EnsureProxyHarness> createState() =>
|
||||
_EnsureProxyHarnessState();
|
||||
}
|
||||
|
||||
class _EnsureProxyHarnessState extends ConsumerState<_EnsureProxyHarness> {
|
||||
bool? _result;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: Column(
|
||||
children: [
|
||||
Text('result:${_result ?? 'pending'}'),
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
final result = await ensureProxyStartedForContainer(
|
||||
context,
|
||||
ref,
|
||||
widget.container,
|
||||
);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_result = result;
|
||||
});
|
||||
},
|
||||
child: const Text('Open container'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ErrorRuntimeRepository extends SingboxProxyRuntimeRepository {
|
||||
final startedProfileIds = <String>[];
|
||||
|
||||
@override
|
||||
Future<SingboxProxyRuntimeState> build() {
|
||||
return Future<SingboxProxyRuntimeState>.error(StateError('runtime failed'));
|
||||
}
|
||||
|
||||
@override
|
||||
Future<SingboxProxyRuntimeState> startProfile(
|
||||
String profileId, {
|
||||
SingboxProxyRuntimeOptions? options,
|
||||
}) async {
|
||||
startedProfileIds.add(profileId);
|
||||
return SingboxProxyRuntimeState(
|
||||
status: SingboxProxyRuntimeStatus.running,
|
||||
endpoints: [
|
||||
SingboxProxyRuntimeEndpoint(
|
||||
profileId: const SingboxProxyConnectionId('profile-1').encode(),
|
||||
host: '127.0.0.1',
|
||||
port: 1080,
|
||||
username: '',
|
||||
password: '',
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
+207
@@ -0,0 +1,207 @@
|
||||
import 'package:flutter_singbox_proxy/flutter_singbox_proxy.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:weblibre/features/proxy/data/models/proxy_profile_seed.dart';
|
||||
import 'package:weblibre/features/proxy/domain/repositories/singbox_proxy_credentials.dart';
|
||||
import 'package:weblibre/features/proxy/domain/repositories/singbox_proxy_profiles.dart';
|
||||
import 'package:weblibre/features/proxy/domain/repositories/singbox_proxy_runtime.dart';
|
||||
import 'package:weblibre/features/proxy/presentation/controllers/proxy_profile_draft_controller.dart';
|
||||
import 'package:weblibre/features/user/data/database/definitions.drift.dart'
|
||||
show ProxyProfile;
|
||||
|
||||
void main() {
|
||||
test('save validates and creates a seeded structured profile', () async {
|
||||
final runtimeRepository = _FakeRuntimeRepository();
|
||||
final profilesRepository = _FakeProfilesRepository();
|
||||
final container = _container(
|
||||
runtimeRepository: runtimeRepository,
|
||||
profilesRepository: profilesRepository,
|
||||
);
|
||||
addTearDown(container.dispose);
|
||||
|
||||
final provider = proxyProfileDraftProvider(
|
||||
seed: ProxyProfileSeed(
|
||||
type: SingboxProxyProfileType.socks,
|
||||
name: 'Imported SOCKS',
|
||||
values: {
|
||||
'server': 'proxy.example',
|
||||
'server_port': '1080',
|
||||
'username': 'alice',
|
||||
'password': 'secret',
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
final outcome = await container.read(provider.notifier).save();
|
||||
|
||||
expect(outcome, isA<SaveSucceeded>());
|
||||
expect(runtimeRepository.validatedProfiles, hasLength(1));
|
||||
expect(profilesRepository.createdProfiles, hasLength(1));
|
||||
expect(profilesRepository.createdProfiles.single.name, 'Imported SOCKS');
|
||||
expect(
|
||||
profilesRepository.createdProfiles.single.configJson,
|
||||
contains('"server": "proxy.example"'),
|
||||
);
|
||||
expect(
|
||||
profilesRepository.createdSecrets.single,
|
||||
contains('"password": "secret"'),
|
||||
);
|
||||
});
|
||||
|
||||
test('editing writes null secret to clear secure storage', () async {
|
||||
final credentialsRepository = _FakeCredentialsRepository(
|
||||
secrets: {'profile-1': '{"password":"old-secret"}'},
|
||||
);
|
||||
final profilesRepository = _FakeProfilesRepository(
|
||||
existingProfile: _profile(
|
||||
id: 'profile-1',
|
||||
name: 'SOCKS',
|
||||
type: SingboxProxyProfileType.socks,
|
||||
configJson:
|
||||
'{"type":"socks","server":"proxy.example","server_port":1080}',
|
||||
),
|
||||
);
|
||||
final container = _container(
|
||||
profilesRepository: profilesRepository,
|
||||
credentialsRepository: credentialsRepository,
|
||||
);
|
||||
addTearDown(container.dispose);
|
||||
|
||||
final provider = proxyProfileDraftProvider(profileId: 'profile-1');
|
||||
final subscription = container.listen(
|
||||
provider,
|
||||
(_, _) {},
|
||||
fireImmediately: true,
|
||||
);
|
||||
addTearDown(subscription.close);
|
||||
await _drainAsyncLoad();
|
||||
|
||||
container.read(provider.notifier).setFieldValue('password', '');
|
||||
final outcome = await container.read(provider.notifier).save();
|
||||
|
||||
expect(outcome, isA<SaveSucceeded>());
|
||||
expect(profilesRepository.updatedProfiles, hasLength(1));
|
||||
expect(credentialsRepository.writtenSecrets, {'profile-1': null});
|
||||
});
|
||||
}
|
||||
|
||||
ProviderContainer _container({
|
||||
_FakeRuntimeRepository? runtimeRepository,
|
||||
_FakeProfilesRepository? profilesRepository,
|
||||
_FakeCredentialsRepository? credentialsRepository,
|
||||
}) {
|
||||
return ProviderContainer(
|
||||
overrides: [
|
||||
singboxProxyRuntimeRepositoryProvider.overrideWith(
|
||||
() => runtimeRepository ?? _FakeRuntimeRepository(),
|
||||
),
|
||||
singboxProxyProfilesRepositoryProvider.overrideWith(
|
||||
() => profilesRepository ?? _FakeProfilesRepository(),
|
||||
),
|
||||
singboxProxyCredentialsRepositoryProvider.overrideWith(
|
||||
() => credentialsRepository ?? _FakeCredentialsRepository(),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _drainAsyncLoad() => pumpEventQueue();
|
||||
|
||||
ProxyProfile _profile({
|
||||
required String id,
|
||||
required String name,
|
||||
required SingboxProxyProfileType type,
|
||||
required String configJson,
|
||||
}) {
|
||||
final createdAt = DateTime(2026);
|
||||
return ProxyProfile(
|
||||
id: id,
|
||||
name: name,
|
||||
type: type,
|
||||
configJson: configJson,
|
||||
createdAt: createdAt,
|
||||
updatedAt: createdAt,
|
||||
);
|
||||
}
|
||||
|
||||
class _FakeRuntimeRepository extends SingboxProxyRuntimeRepository {
|
||||
final validatedProfiles = <ProxyProfile>[];
|
||||
final validatedSecrets = <String?>[];
|
||||
|
||||
@override
|
||||
Future<String?> validateProfileDraft(
|
||||
ProxyProfile profile, {
|
||||
String? secretJson,
|
||||
}) async {
|
||||
validatedProfiles.add(profile);
|
||||
validatedSecrets.add(secretJson);
|
||||
return null;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<SingboxProxyRuntimeState> build() async {
|
||||
return SingboxProxyRuntimeState(
|
||||
status: SingboxProxyRuntimeStatus.stopped,
|
||||
endpoints: const [],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _FakeProfilesRepository extends SingboxProxyProfilesRepository {
|
||||
final ProxyProfile? existingProfile;
|
||||
final createdProfiles = <ProxyProfile>[];
|
||||
final createdSecrets = <String?>[];
|
||||
final updatedProfiles = <ProxyProfile>[];
|
||||
|
||||
_FakeProfilesRepository({this.existingProfile});
|
||||
|
||||
@override
|
||||
Future<ProxyProfile?> findProfile(String id) async => existingProfile;
|
||||
|
||||
@override
|
||||
Future<ProxyProfile> createProfile({
|
||||
required String name,
|
||||
required SingboxProxyProfileType type,
|
||||
required String configJson,
|
||||
String? secretJson,
|
||||
String? dnsOverrideJson,
|
||||
}) async {
|
||||
final profile = _profile(
|
||||
id: 'created-profile',
|
||||
name: name,
|
||||
type: type,
|
||||
configJson: configJson,
|
||||
);
|
||||
createdProfiles.add(profile);
|
||||
createdSecrets.add(secretJson);
|
||||
return profile;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> updateProfile(ProxyProfile profile, {String? secretJson}) async {
|
||||
updatedProfiles.add(profile);
|
||||
}
|
||||
|
||||
@override
|
||||
Stream<List<ProxyProfile>> build() {
|
||||
return Stream.value([if (existingProfile != null) existingProfile!]);
|
||||
}
|
||||
}
|
||||
|
||||
class _FakeCredentialsRepository extends SingboxProxyCredentialsRepository {
|
||||
final Map<String, String?> secrets;
|
||||
final writtenSecrets = <String, String?>{};
|
||||
|
||||
_FakeCredentialsRepository({this.secrets = const {}});
|
||||
|
||||
@override
|
||||
Future<String?> readSecretJson(String profileId) async => secrets[profileId];
|
||||
|
||||
@override
|
||||
Future<void> writeSecretJson(String profileId, String? secretJson) async {
|
||||
writtenSecrets[profileId] = secretJson;
|
||||
}
|
||||
|
||||
@override
|
||||
void build() {}
|
||||
}
|
||||
+216
@@ -0,0 +1,216 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_singbox_proxy/flutter_singbox_proxy.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:weblibre/features/proxy/data/models/proxy_profile_seed.dart';
|
||||
import 'package:weblibre/features/proxy/data/proxy_connection.dart';
|
||||
import 'package:weblibre/features/proxy/domain/repositories/singbox_proxy_credentials.dart';
|
||||
import 'package:weblibre/features/proxy/domain/repositories/singbox_proxy_profiles.dart';
|
||||
import 'package:weblibre/features/proxy/domain/repositories/singbox_proxy_runtime.dart';
|
||||
import 'package:weblibre/features/proxy/presentation/screens/singbox_proxy_profile_editor.dart';
|
||||
import 'package:weblibre/features/proxy/presentation/screens/singbox_proxy_profiles.dart';
|
||||
import 'package:weblibre/features/proxy/presentation/widgets/profile_list/profile_tile.dart';
|
||||
import 'package:weblibre/features/user/data/database/definitions.drift.dart'
|
||||
show ProxyProfile;
|
||||
|
||||
void main() {
|
||||
testWidgets('deleting a running profile stops through runtime repository', (
|
||||
tester,
|
||||
) async {
|
||||
final profile = _profile(id: 'profile-1', name: 'Mullvad');
|
||||
final profilesRepository = _FakeProfilesRepository([profile]);
|
||||
final runtimeRepository = _FakeRuntimeRepository(
|
||||
SingboxProxyRuntimeState(
|
||||
status: SingboxProxyRuntimeStatus.running,
|
||||
endpoints: [
|
||||
SingboxProxyRuntimeEndpoint(
|
||||
profileId: SingboxProxyConnectionId(profile.id).encode(),
|
||||
host: '127.0.0.1',
|
||||
port: 12080,
|
||||
username: 'user',
|
||||
password: 'pass',
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
singboxProxyProfilesRepositoryProvider.overrideWith(
|
||||
() => profilesRepository,
|
||||
),
|
||||
singboxProxyRuntimeRepositoryProvider.overrideWith(
|
||||
() => runtimeRepository,
|
||||
),
|
||||
],
|
||||
child: const MaterialApp(home: SingboxProxyProfilesScreen()),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Mullvad'), findsOneWidget);
|
||||
|
||||
await tester.tap(
|
||||
find.descendant(
|
||||
of: find.byType(ProfileTile),
|
||||
matching: find.byIcon(Icons.more_vert),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Delete'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Stop and Delete'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text('Stop and Delete'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(runtimeRepository.deletedProfileIds, ['profile-1']);
|
||||
});
|
||||
|
||||
testWidgets('proxy URI import populates structured editor fields', (
|
||||
tester,
|
||||
) async {
|
||||
await _pumpEditor(
|
||||
tester,
|
||||
profilesRepository: _FakeProfilesRepository([]),
|
||||
seed: ProxyProfileSeed(
|
||||
type: SingboxProxyProfileType.vless,
|
||||
name: 'Imported VLESS',
|
||||
values: {
|
||||
'server': 'vless.example.com',
|
||||
'server_port': '443',
|
||||
'uuid': '00000000-0000-0000-0000-000000000000',
|
||||
'flow': 'xtls-rprx-vision',
|
||||
'tls.server_name': 'front.example.com',
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.text('Imported VLESS'), findsOneWidget);
|
||||
expect(find.text('VLESS'), findsOneWidget);
|
||||
expect(find.text('Connection'), findsWidgets);
|
||||
expect(find.text('Credentials'), findsOneWidget);
|
||||
expect(find.text('TLS'), findsOneWidget);
|
||||
expect(_fieldText(tester, 'Server Address *'), 'vless.example.com');
|
||||
expect(_fieldText(tester, 'Server Port *'), '443');
|
||||
expect(
|
||||
_fieldText(tester, 'UUID *'),
|
||||
'00000000-0000-0000-0000-000000000000',
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('WireGuard config import populates WireGuard form fields', (
|
||||
tester,
|
||||
) async {
|
||||
await _pumpEditor(
|
||||
tester,
|
||||
profilesRepository: _FakeProfilesRepository([]),
|
||||
seed: ProxyProfileSeed(
|
||||
type: SingboxProxyProfileType.wireguard,
|
||||
values: {
|
||||
'server': 'wg.example.com',
|
||||
'server_port': '51820',
|
||||
'local_address': '10.0.0.2/32',
|
||||
'private_key': 'private-key',
|
||||
'peer_public_key': 'peer-public-key',
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
expect(_fieldText(tester, 'Server Address *'), 'wg.example.com');
|
||||
expect(_fieldText(tester, 'Server Port *'), '51820');
|
||||
expect(_fieldText(tester, 'Local Address *'), '10.0.0.2/32');
|
||||
expect(_fieldText(tester, 'Private Key *'), 'private-key');
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _pumpEditor(
|
||||
WidgetTester tester, {
|
||||
required _FakeProfilesRepository profilesRepository,
|
||||
String? profileId,
|
||||
ProxyProfileSeed? seed,
|
||||
}) async {
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
singboxProxyProfilesRepositoryProvider.overrideWith(
|
||||
() => profilesRepository,
|
||||
),
|
||||
singboxProxyCredentialsRepositoryProvider.overrideWith(
|
||||
_FakeCredentialsRepository.new,
|
||||
),
|
||||
singboxProxyRuntimeRepositoryProvider.overrideWith(
|
||||
() => _FakeRuntimeRepository(
|
||||
SingboxProxyRuntimeState(
|
||||
status: SingboxProxyRuntimeStatus.stopped,
|
||||
endpoints: const [],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
child: MaterialApp(
|
||||
home: SingboxProxyProfileEditorScreen(profileId: profileId, seed: seed),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
String _fieldText(WidgetTester tester, String labelText) {
|
||||
final textField = tester.widget<TextField>(
|
||||
find.ancestor(of: find.text(labelText), matching: find.byType(TextField)),
|
||||
);
|
||||
return textField.controller!.text;
|
||||
}
|
||||
|
||||
ProxyProfile _profile({
|
||||
required String id,
|
||||
required String name,
|
||||
SingboxProxyProfileType type = SingboxProxyProfileType.customOutbound,
|
||||
String configJson = '{"type":"socks"}',
|
||||
}) {
|
||||
final createdAt = DateTime(2026);
|
||||
|
||||
return ProxyProfile(
|
||||
id: id,
|
||||
name: name,
|
||||
type: type,
|
||||
configJson: configJson,
|
||||
createdAt: createdAt,
|
||||
updatedAt: createdAt,
|
||||
);
|
||||
}
|
||||
|
||||
class _FakeProfilesRepository extends SingboxProxyProfilesRepository {
|
||||
final List<ProxyProfile> profiles;
|
||||
|
||||
_FakeProfilesRepository(this.profiles);
|
||||
|
||||
@override
|
||||
Stream<List<ProxyProfile>> build() => Stream.value(profiles);
|
||||
}
|
||||
|
||||
class _FakeCredentialsRepository extends SingboxProxyCredentialsRepository {
|
||||
@override
|
||||
Future<String?> readSecretJson(String profileId) async => null;
|
||||
|
||||
@override
|
||||
void build() {}
|
||||
}
|
||||
|
||||
class _FakeRuntimeRepository extends SingboxProxyRuntimeRepository {
|
||||
final SingboxProxyRuntimeState initialState;
|
||||
final deletedProfileIds = <String>[];
|
||||
|
||||
_FakeRuntimeRepository(this.initialState);
|
||||
|
||||
@override
|
||||
Future<SingboxProxyRuntimeState> build() async => initialState;
|
||||
|
||||
@override
|
||||
Future<void> deleteProfile(String profileId) async {
|
||||
deletedProfileIds.add(profileId);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user