Add proxy routing and sing-box support
This commit is contained in:
@@ -9,6 +9,9 @@ import 'schema_v2.dart' as v2;
|
||||
import 'schema_v3.dart' as v3;
|
||||
import 'schema_v4.dart' as v4;
|
||||
import 'schema_v5.dart' as v5;
|
||||
import 'schema_v6.dart' as v6;
|
||||
import 'schema_v7.dart' as v7;
|
||||
import 'schema_v8.dart' as v8;
|
||||
|
||||
class GeneratedHelper implements SchemaInstantiationHelper {
|
||||
@override
|
||||
@@ -24,10 +27,16 @@ class GeneratedHelper implements SchemaInstantiationHelper {
|
||||
return v4.DatabaseAtV4(db);
|
||||
case 5:
|
||||
return v5.DatabaseAtV5(db);
|
||||
case 6:
|
||||
return v6.DatabaseAtV6(db);
|
||||
case 7:
|
||||
return v7.DatabaseAtV7(db);
|
||||
case 8:
|
||||
return v8.DatabaseAtV8(db);
|
||||
default:
|
||||
throw MissingSchemaException(version, versions);
|
||||
}
|
||||
}
|
||||
|
||||
static const versions = const [1, 2, 3, 4, 5];
|
||||
static const versions = const [1, 2, 3, 4, 5, 6, 7, 8];
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+174
@@ -0,0 +1,174 @@
|
||||
import 'dart:async';
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:drift/native.dart';
|
||||
import 'package:flutter_singbox_proxy/flutter_singbox_proxy.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:flutter_tor/flutter_tor.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:weblibre/data/database/functions/lexo_rank_functions.dart';
|
||||
import 'package:weblibre/data/database/functions/url_functions.dart';
|
||||
import 'package:weblibre/features/geckoview/features/browser/domain/services/proxy_settings_replication.dart';
|
||||
import 'package:weblibre/features/geckoview/features/tabs/data/database/database.dart';
|
||||
import 'package:weblibre/features/geckoview/features/tabs/data/models/container_data.dart';
|
||||
import 'package:weblibre/features/geckoview/features/tabs/data/models/site_assignment.dart';
|
||||
import 'package:weblibre/features/geckoview/features/tabs/data/providers.dart';
|
||||
import 'package:weblibre/features/geckoview/features/tabs/domain/providers.dart';
|
||||
import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/container.dart';
|
||||
import 'package:weblibre/features/proxy/data/proxy_connection.dart';
|
||||
import 'package:weblibre/features/proxy/domain/repositories/container_proxy.dart';
|
||||
import 'package:weblibre/features/proxy/domain/repositories/singbox_proxy_runtime.dart';
|
||||
import 'package:weblibre/features/tor/domain/services/tor_proxy.dart';
|
||||
import 'package:weblibre/features/user/data/models/proxy_routing_settings.dart';
|
||||
import 'package:weblibre/features/user/domain/repositories/proxy_routing_settings.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
test(
|
||||
'container assignments ensure sing-box profiles are running before syncing',
|
||||
() async {
|
||||
final profileId = SingboxProxyConnectionId('profile-1');
|
||||
final assignedContainer = _container(
|
||||
id: 'container-1',
|
||||
contextId: 'context-a',
|
||||
proxyConnectionId: profileId,
|
||||
);
|
||||
final db = TabDatabase(
|
||||
NativeDatabase.memory(
|
||||
setup: (database) {
|
||||
registerLexorankFunctions(database);
|
||||
registerUrlFunctions(database);
|
||||
},
|
||||
),
|
||||
);
|
||||
final containerProxyRepository = _FakeContainerProxyRepository();
|
||||
final containerRepository = _FakeContainerRepository([assignedContainer]);
|
||||
final runtimeRepository = _FakeSingboxProxyRuntimeRepository();
|
||||
final container = ProviderContainer(
|
||||
overrides: [
|
||||
tabDatabaseProvider.overrideWith((ref) => db),
|
||||
containerProxyRepositoryProvider.overrideWith(
|
||||
() => containerProxyRepository,
|
||||
),
|
||||
containerRepositoryProvider.overrideWith(() => containerRepository),
|
||||
singboxProxyRuntimeRepositoryProvider.overrideWith(
|
||||
() => runtimeRepository,
|
||||
),
|
||||
torProxyServiceProvider.overrideWith(_FakeTorProxyService.new),
|
||||
proxyRoutingSettingsWithDefaultsProvider.overrideWith(
|
||||
(ref) => ProxyRoutingSettings.withDefaults(),
|
||||
),
|
||||
watchContainersWithCountProvider.overrideWith(
|
||||
(ref) => Stream.value([assignedContainer]),
|
||||
),
|
||||
watchAllAssignedSitesProvider.overrideWith(
|
||||
(ref) => Stream.value(const <SiteAssignment>[]),
|
||||
),
|
||||
watchIsolatedContextContainerMapProvider.overrideWith(
|
||||
(ref) => Stream.value(const <String, Set<String>>{}),
|
||||
),
|
||||
],
|
||||
);
|
||||
addTearDown(() async {
|
||||
container.dispose();
|
||||
await db.close();
|
||||
});
|
||||
|
||||
final subscription = container.listen<void>(
|
||||
proxySettingsReplicationProvider,
|
||||
(previous, next) {},
|
||||
fireImmediately: true,
|
||||
);
|
||||
addTearDown(subscription.close);
|
||||
await pumpEventQueue();
|
||||
|
||||
expect(runtimeRepository.ensuredProxyConnectionIds, [profileId]);
|
||||
expect(containerProxyRepository.setContainerProxyCalls, [
|
||||
('context-a', profileId.encode()),
|
||||
]);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
ContainerDataWithCount _container({
|
||||
required String id,
|
||||
required String contextId,
|
||||
required ProxyConnectionId proxyConnectionId,
|
||||
}) {
|
||||
return ContainerDataWithCount(
|
||||
id: id,
|
||||
name: 'Container',
|
||||
color: const Color(0xFF336699),
|
||||
orderKey: 'a',
|
||||
metadata: ContainerMetadata.withDefaults(
|
||||
contextualIdentity: contextId,
|
||||
proxyConnectionId: proxyConnectionId,
|
||||
),
|
||||
tabCount: 0,
|
||||
);
|
||||
}
|
||||
|
||||
class _FakeContainerProxyRepository extends ContainerProxyRepository {
|
||||
final setContainerProxyCalls = <(String, String)>[];
|
||||
|
||||
@override
|
||||
Future<void> setTorProxyPort(int? port) async {}
|
||||
|
||||
@override
|
||||
Future<void> setSiteAssignments(List<SiteAssignment> assignements) async {}
|
||||
|
||||
@override
|
||||
Future<void> setContainerProxy(String contextId, String proxyId) async {
|
||||
setContainerProxyCalls.add((contextId, proxyId));
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> clearContainerProxy(String contextId) async {}
|
||||
|
||||
@override
|
||||
void build() {}
|
||||
}
|
||||
|
||||
class _FakeContainerRepository extends ContainerRepository {
|
||||
final List<ContainerDataWithCount> containers;
|
||||
|
||||
_FakeContainerRepository(this.containers);
|
||||
|
||||
@override
|
||||
Future<List<ContainerDataWithCount>> getAllContainersWithCount() async {
|
||||
return containers;
|
||||
}
|
||||
|
||||
@override
|
||||
void build() {}
|
||||
}
|
||||
|
||||
class _FakeSingboxProxyRuntimeRepository extends SingboxProxyRuntimeRepository {
|
||||
final ensuredProxyConnectionIds = <SingboxProxyConnectionId>[];
|
||||
|
||||
@override
|
||||
Future<void> ensureProxyConnectionAvailable(
|
||||
SingboxProxyConnectionId connectionId,
|
||||
) async {
|
||||
ensuredProxyConnectionIds.add(connectionId);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<SingboxProxyRuntimeState> build() async {
|
||||
return SingboxProxyRuntimeState(
|
||||
status: SingboxProxyRuntimeStatus.stopped,
|
||||
endpoints: [],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _FakeTorProxyService extends TorProxyService {
|
||||
@override
|
||||
Stream<TorStatus> build() async* {
|
||||
// Mirror production: the real TorProxyService seeds the provider with the
|
||||
// current status so downstream `selectAsync` paths don't stay in
|
||||
// AsyncLoading forever. Tor is stopped in these tests.
|
||||
yield TorStatus(isRunning: false, bootstrapProgress: 0);
|
||||
}
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:weblibre/features/geckoview/features/tabs/data/models/container_data.dart';
|
||||
import 'package:weblibre/features/proxy/data/proxy_connection.dart';
|
||||
|
||||
void main() {
|
||||
group('ContainerMetadata proxy serialization', () {
|
||||
test('round-trips proxyConnectionId through JSON', () {
|
||||
final metadata = ContainerMetadata.withDefaults(
|
||||
proxyConnectionId: const SingboxProxyConnectionId('profile-1'),
|
||||
clearDataOnExit: true,
|
||||
);
|
||||
|
||||
final json = metadata.toJson();
|
||||
final restored = ContainerMetadata.fromJson(json);
|
||||
|
||||
expect(
|
||||
json['proxyConnectionId'],
|
||||
const SingboxProxyConnectionId('profile-1').encode(),
|
||||
);
|
||||
expect(json['clearDataOnExit'], isTrue);
|
||||
expect(restored.proxyConnectionId, metadata.proxyConnectionId);
|
||||
expect(restored.usesTorProxy, isFalse);
|
||||
});
|
||||
|
||||
test('usesTorProxy is true only for the Tor connection id', () {
|
||||
final tor = ContainerMetadata.withDefaults(
|
||||
proxyConnectionId: const TorProxyConnectionId(),
|
||||
);
|
||||
|
||||
expect(tor.usesTorProxy, isTrue);
|
||||
});
|
||||
});
|
||||
}
|
||||
+86
@@ -0,0 +1,86 @@
|
||||
import 'package:flutter/material.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/geckoview/features/tabs/presentation/screens/container_edit.dart';
|
||||
import 'package:weblibre/features/proxy/data/proxy_connection.dart';
|
||||
import 'package:weblibre/features/proxy/domain/providers/proxy_connection_options.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets(
|
||||
'clearing proxy selection does not leave cookie isolation enabled in create mode',
|
||||
(tester) async {
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
proxyConnectionOptionsProvider.overrideWith(
|
||||
(ref) => const <ProxyConnectionOption>[],
|
||||
),
|
||||
],
|
||||
child: MaterialApp(
|
||||
home: ContainerEditScreen.create(
|
||||
initialContainer: ContainerData(
|
||||
id: 'container-1',
|
||||
color: Colors.blue,
|
||||
orderKey: 'a',
|
||||
metadata: ContainerMetadata.withDefaults(
|
||||
proxyConnectionId: SingboxProxyConnectionId('missing-proxy'),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.text('Proxy Connection'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Clear'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final cookieIsolationTile = tester.widget<SwitchListTile>(
|
||||
find.widgetWithText(SwitchListTile, 'Cookie Isolation'),
|
||||
);
|
||||
|
||||
expect(cookieIsolationTile.value, isFalse);
|
||||
expect(find.text('None'), findsOneWidget);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'dismissing proxy picker does not leave cookie isolation enabled in create mode',
|
||||
(tester) async {
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
proxyConnectionOptionsProvider.overrideWith(
|
||||
(ref) => const <ProxyConnectionOption>[],
|
||||
),
|
||||
],
|
||||
child: MaterialApp(
|
||||
home: ContainerEditScreen.create(
|
||||
initialContainer: ContainerData(
|
||||
id: 'container-1',
|
||||
color: Colors.blue,
|
||||
orderKey: 'a',
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.text('Proxy Connection'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tapAt(const Offset(8, 8));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final cookieIsolationTile = tester.widget<SwitchListTile>(
|
||||
find.widgetWithText(SwitchListTile, 'Cookie Isolation'),
|
||||
);
|
||||
|
||||
expect(cookieIsolationTile.value, isFalse);
|
||||
expect(find.text('New Container'), findsOneWidget);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter_singbox_proxy/flutter_singbox_proxy.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:weblibre/features/proxy/data/forms/singbox_form_specs.dart';
|
||||
import 'package:weblibre/features/proxy/data/parsers/singbox_proxy_uri.dart';
|
||||
|
||||
void main() {
|
||||
group('SingboxProxyFormSpec', () {
|
||||
test('builds public and secret JSON for Shadowsocks', () {
|
||||
final spec = singboxProxyFormSpecs[SingboxProxyProfileType.shadowsocks]!;
|
||||
final values = {
|
||||
'server': 'ss.example.com',
|
||||
'server_port': '8388',
|
||||
'method': '2022-blake3-aes-128-gcm',
|
||||
'password': 'secret',
|
||||
};
|
||||
|
||||
expect(spec.validate(values), isNull);
|
||||
|
||||
final config = jsonDecode(spec.toConfigJson(values));
|
||||
final secrets = jsonDecode(spec.toSecretJson(values)!);
|
||||
|
||||
expect(config, {
|
||||
'type': 'shadowsocks',
|
||||
'server': 'ss.example.com',
|
||||
'server_port': 8388,
|
||||
'method': '2022-blake3-aes-128-gcm',
|
||||
});
|
||||
expect(secrets, {'password': 'secret'});
|
||||
});
|
||||
|
||||
test('hydrates structured values from public and secret JSON', () {
|
||||
final spec = singboxProxyFormSpecs[SingboxProxyProfileType.vless]!;
|
||||
|
||||
final values = spec.valuesFromJson(
|
||||
configJson: '''
|
||||
{
|
||||
"type": "vless",
|
||||
"server": "vless.example.com",
|
||||
"server_port": 443,
|
||||
"flow": "xtls-rprx-vision"
|
||||
}''',
|
||||
secretJson: '{"uuid":"00000000-0000-0000-0000-000000000000"}',
|
||||
);
|
||||
|
||||
expect(values['server'], 'vless.example.com');
|
||||
expect(values['server_port'], '443');
|
||||
expect(values['uuid'], '00000000-0000-0000-0000-000000000000');
|
||||
expect(values['flow'], 'xtls-rprx-vision');
|
||||
});
|
||||
|
||||
test('rejects invalid server ports', () {
|
||||
final spec = singboxProxyFormSpecs[SingboxProxyProfileType.http]!;
|
||||
|
||||
expect(
|
||||
spec.validate({'server': 'proxy.example.com', 'server_port': '70000'}),
|
||||
'Server Port must be between 1 and 65535.',
|
||||
);
|
||||
});
|
||||
|
||||
test('builds nested advanced config fields', () {
|
||||
final spec = singboxProxyFormSpecs[SingboxProxyProfileType.vless]!;
|
||||
final values = {
|
||||
'server': 'vless.example.com',
|
||||
'server_port': '443',
|
||||
'uuid': '00000000-0000-0000-0000-000000000000',
|
||||
'flow': 'xtls-rprx-vision',
|
||||
'tls.enabled': 'true',
|
||||
'tls.server_name': 'front.example.com',
|
||||
'tls.alpn': 'h2, http/1.1',
|
||||
'transport.type': 'ws',
|
||||
'transport.path': '/proxy',
|
||||
'multiplex.enabled': 'true',
|
||||
'multiplex.max_connections': '4',
|
||||
'domain_strategy': 'prefer_ipv4',
|
||||
};
|
||||
|
||||
expect(spec.validate(values), isNull);
|
||||
|
||||
final config =
|
||||
jsonDecode(spec.toConfigJson(values)) as Map<String, dynamic>;
|
||||
final secrets =
|
||||
jsonDecode(spec.toSecretJson(values)!) as Map<String, dynamic>;
|
||||
|
||||
expect(config['tls'], {
|
||||
'enabled': true,
|
||||
'server_name': 'front.example.com',
|
||||
'alpn': ['h2', 'http/1.1'],
|
||||
});
|
||||
expect(config['transport'], {'type': 'ws', 'path': '/proxy'});
|
||||
expect(config['multiplex'], {'enabled': true, 'max_connections': 4});
|
||||
expect(config['domain_strategy'], 'prefer_ipv4');
|
||||
expect(secrets, {'uuid': '00000000-0000-0000-0000-000000000000'});
|
||||
});
|
||||
|
||||
test('builds public and secret JSON for WireGuard', () {
|
||||
final spec = singboxProxyFormSpecs[SingboxProxyProfileType.wireguard]!;
|
||||
final values = {
|
||||
'server': 'wg.example.com',
|
||||
'server_port': '51820',
|
||||
'local_address': '10.0.0.2/32\nfd00::2/128',
|
||||
'private_key': 'private-key',
|
||||
'peer_public_key': 'peer-public-key',
|
||||
'pre_shared_key': 'pre-shared-key',
|
||||
'mtu': '1420',
|
||||
'reserved': '1, 2, 3',
|
||||
};
|
||||
|
||||
expect(spec.validate(values), isNull);
|
||||
|
||||
final config = jsonDecode(spec.toConfigJson(values));
|
||||
final secrets = jsonDecode(spec.toSecretJson(values)!);
|
||||
|
||||
expect(config, {
|
||||
'type': 'wireguard',
|
||||
'server': 'wg.example.com',
|
||||
'server_port': 51820,
|
||||
'local_address': ['10.0.0.2/32', 'fd00::2/128'],
|
||||
'peer_public_key': 'peer-public-key',
|
||||
'mtu': 1420,
|
||||
'reserved': [1, 2, 3],
|
||||
});
|
||||
expect(secrets, {
|
||||
'private_key': 'private-key',
|
||||
'pre_shared_key': 'pre-shared-key',
|
||||
});
|
||||
});
|
||||
|
||||
test('hydrates WireGuard values from public and secret JSON', () {
|
||||
final spec = singboxProxyFormSpecs[SingboxProxyProfileType.wireguard]!;
|
||||
|
||||
final values = spec.valuesFromJson(
|
||||
configJson: '''
|
||||
{
|
||||
"type": "wireguard",
|
||||
"server": "wg.example.com",
|
||||
"server_port": 51820,
|
||||
"local_address": ["10.0.0.2/32"],
|
||||
"peer_public_key": "peer-public-key",
|
||||
"mtu": 1420,
|
||||
"reserved": [1, 2, 3]
|
||||
}''',
|
||||
secretJson: '''
|
||||
{
|
||||
"private_key": "private-key",
|
||||
"pre_shared_key": "pre-shared-key"
|
||||
}''',
|
||||
);
|
||||
|
||||
expect(values['server'], 'wg.example.com');
|
||||
expect(values['server_port'], '51820');
|
||||
expect(values['local_address'], '10.0.0.2/32');
|
||||
expect(values['private_key'], 'private-key');
|
||||
expect(values['peer_public_key'], 'peer-public-key');
|
||||
expect(values['pre_shared_key'], 'pre-shared-key');
|
||||
expect(values['mtu'], '1420');
|
||||
expect(values['reserved'], '1\n2\n3');
|
||||
});
|
||||
|
||||
test('rejects invalid WireGuard reserved bytes', () {
|
||||
final spec = singboxProxyFormSpecs[SingboxProxyProfileType.wireguard]!;
|
||||
|
||||
expect(
|
||||
spec.validate({
|
||||
'server': 'wg.example.com',
|
||||
'server_port': '51820',
|
||||
'local_address': '10.0.0.2/32',
|
||||
'private_key': 'private-key',
|
||||
'peer_public_key': 'peer-public-key',
|
||||
'mtu': '1420',
|
||||
'reserved': '1, 2',
|
||||
}),
|
||||
'Reserved Bytes must contain 3 numbers.',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('importSingboxProxyUri', () {
|
||||
test('imports Shadowsocks SIP002 URI', () {
|
||||
final credentials = base64Url.encode(
|
||||
utf8.encode('aes-256-gcm:secret-password'),
|
||||
);
|
||||
|
||||
final imported = importSingboxProxyUri(
|
||||
'ss://$credentials@ss.example.com:8388#Work%20SS',
|
||||
);
|
||||
|
||||
expect(imported.type, SingboxProxyProfileType.shadowsocks);
|
||||
expect(imported.name, 'Work SS');
|
||||
expect(imported.values, {
|
||||
'server': 'ss.example.com',
|
||||
'server_port': '8388',
|
||||
'method': 'aes-256-gcm',
|
||||
'password': 'secret-password',
|
||||
});
|
||||
});
|
||||
|
||||
test('imports legacy base64 Shadowsocks URI', () {
|
||||
final payload = base64Url.encode(
|
||||
utf8.encode('chacha20-ietf-poly1305:secret@[2001:db8::1]:8388'),
|
||||
);
|
||||
|
||||
final imported = importSingboxProxyUri('ss://$payload');
|
||||
|
||||
expect(imported.type, SingboxProxyProfileType.shadowsocks);
|
||||
expect(imported.values['server'], '2001:db8::1');
|
||||
expect(imported.values['server_port'], '8388');
|
||||
expect(imported.values['method'], 'chacha20-ietf-poly1305');
|
||||
expect(imported.values['password'], 'secret');
|
||||
});
|
||||
|
||||
test('imports Trojan URI', () {
|
||||
final imported = importSingboxProxyUri(
|
||||
'trojan://secret%20password@trojan.example.com:443#Trojan',
|
||||
);
|
||||
|
||||
expect(imported.type, SingboxProxyProfileType.trojan);
|
||||
expect(imported.name, 'Trojan');
|
||||
expect(imported.values, {
|
||||
'server': 'trojan.example.com',
|
||||
'server_port': '443',
|
||||
'password': 'secret password',
|
||||
'tls.enabled': 'true',
|
||||
});
|
||||
});
|
||||
|
||||
test('imports VLESS URI', () {
|
||||
final imported = importSingboxProxyUri(
|
||||
'vless://00000000-0000-0000-0000-000000000000@vless.example.com:443?flow=xtls-rprx-vision#VLESS',
|
||||
);
|
||||
|
||||
expect(imported.type, SingboxProxyProfileType.vless);
|
||||
expect(imported.name, 'VLESS');
|
||||
expect(imported.values, {
|
||||
'server': 'vless.example.com',
|
||||
'server_port': '443',
|
||||
'uuid': '00000000-0000-0000-0000-000000000000',
|
||||
'flow': 'xtls-rprx-vision',
|
||||
});
|
||||
});
|
||||
|
||||
test('imports VMess URI', () {
|
||||
final payload = base64Url.encode(
|
||||
utf8.encode(
|
||||
jsonEncode({
|
||||
'v': '2',
|
||||
'ps': 'VMess',
|
||||
'add': 'vmess.example.com',
|
||||
'port': '443',
|
||||
'id': '00000000-0000-0000-0000-000000000000',
|
||||
'aid': '0',
|
||||
'scy': 'auto',
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
final imported = importSingboxProxyUri('vmess://$payload');
|
||||
|
||||
expect(imported.type, SingboxProxyProfileType.vmess);
|
||||
expect(imported.name, 'VMess');
|
||||
expect(imported.values, {
|
||||
'server': 'vmess.example.com',
|
||||
'server_port': '443',
|
||||
'uuid': '00000000-0000-0000-0000-000000000000',
|
||||
'security': 'auto',
|
||||
'alter_id': '0',
|
||||
'tls.enabled': '',
|
||||
'tls.server_name': '',
|
||||
'transport.type': '',
|
||||
'transport.path': '',
|
||||
});
|
||||
});
|
||||
|
||||
test('imports SOCKS URI', () {
|
||||
final imported = importSingboxProxyUri(
|
||||
'socks://user:secret@socks.example.com:1080#SOCKS',
|
||||
);
|
||||
|
||||
expect(imported.type, SingboxProxyProfileType.socks);
|
||||
expect(imported.name, 'SOCKS');
|
||||
expect(imported.values, {
|
||||
'server': 'socks.example.com',
|
||||
'server_port': '1080',
|
||||
'version': '5',
|
||||
'username': 'user',
|
||||
'password': 'secret',
|
||||
});
|
||||
});
|
||||
|
||||
test('imports HTTPS proxy URI', () {
|
||||
final imported = importSingboxProxyUri(
|
||||
'https://user:secret@proxy.example.com:443?sni=proxy.example.com#HTTP',
|
||||
);
|
||||
|
||||
expect(imported.type, SingboxProxyProfileType.http);
|
||||
expect(imported.values['server'], 'proxy.example.com');
|
||||
expect(imported.values['server_port'], '443');
|
||||
expect(imported.values['username'], 'user');
|
||||
expect(imported.values['password'], 'secret');
|
||||
expect(imported.values['tls.enabled'], 'true');
|
||||
expect(imported.values['tls.server_name'], 'proxy.example.com');
|
||||
});
|
||||
|
||||
test('imports Hysteria2 URI', () {
|
||||
final imported = importSingboxProxyUri(
|
||||
'hy2://secret@hy2.example.com:443?obfs=salamander&obfs-password=obfs-secret&sni=front.example.com#HY2',
|
||||
);
|
||||
|
||||
expect(imported.type, SingboxProxyProfileType.hysteria2);
|
||||
expect(imported.name, 'HY2');
|
||||
expect(imported.values['server'], 'hy2.example.com');
|
||||
expect(imported.values['server_port'], '443');
|
||||
expect(imported.values['password'], 'secret');
|
||||
expect(imported.values['obfs.type'], 'salamander');
|
||||
expect(imported.values['obfs.password'], 'obfs-secret');
|
||||
expect(imported.values['tls.enabled'], 'true');
|
||||
expect(imported.values['tls.server_name'], 'front.example.com');
|
||||
});
|
||||
|
||||
test('imports TUIC URI', () {
|
||||
final imported = importSingboxProxyUri(
|
||||
'tuic://00000000-0000-0000-0000-000000000000:secret@tuic.example.com:443?congestion_control=bbr&udp_relay_mode=quic&sni=front.example.com#TUIC',
|
||||
);
|
||||
|
||||
expect(imported.type, SingboxProxyProfileType.tuic);
|
||||
expect(imported.name, 'TUIC');
|
||||
expect(imported.values['server'], 'tuic.example.com');
|
||||
expect(imported.values['server_port'], '443');
|
||||
expect(imported.values['uuid'], '00000000-0000-0000-0000-000000000000');
|
||||
expect(imported.values['password'], 'secret');
|
||||
expect(imported.values['congestion_control'], 'bbr');
|
||||
expect(imported.values['udp_relay_mode'], 'quic');
|
||||
expect(imported.values['tls.enabled'], 'true');
|
||||
expect(imported.values['tls.server_name'], 'front.example.com');
|
||||
});
|
||||
|
||||
test('rejects unsupported URI schemes', () {
|
||||
expect(
|
||||
() => importSingboxProxyUri('hysteria2://example.com:443'),
|
||||
throwsFormatException,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:weblibre/features/proxy/data/models/wireguard_config_import.dart';
|
||||
|
||||
void main() {
|
||||
group('WireguardConfigImport', () {
|
||||
test('imports standard WireGuard config text', () {
|
||||
final imported = WireguardConfigImport.fromConfigText('''
|
||||
[Interface]
|
||||
PrivateKey = private-key
|
||||
Address = 10.0.0.2/32, fd00::2/128
|
||||
MTU = 1420
|
||||
DNS = 1.1.1.1
|
||||
|
||||
[Peer]
|
||||
PublicKey = peer-public-key
|
||||
PresharedKey = pre-shared-key
|
||||
AllowedIPs = 0.0.0.0/0, ::/0
|
||||
Endpoint = wg.example.com:51820
|
||||
''');
|
||||
|
||||
expect(imported.values, {
|
||||
'server': 'wg.example.com',
|
||||
'server_port': '51820',
|
||||
'local_address': '10.0.0.2/32\nfd00::2/128',
|
||||
'private_key': 'private-key',
|
||||
'peer_public_key': 'peer-public-key',
|
||||
'pre_shared_key': 'pre-shared-key',
|
||||
'mtu': '1420',
|
||||
});
|
||||
expect(imported.primaryDnsAddress, 'udp://1.1.1.1');
|
||||
});
|
||||
|
||||
test('imports bracketed IPv6 WireGuard endpoint', () {
|
||||
final imported = WireguardConfigImport.fromConfigText('''
|
||||
[Interface]
|
||||
PrivateKey = private-key
|
||||
Address = fd00::2/128
|
||||
|
||||
[Peer]
|
||||
PublicKey = peer-public-key
|
||||
Endpoint = [2001:db8::1]:51820
|
||||
''');
|
||||
|
||||
expect(imported.values['server'], '2001:db8::1');
|
||||
expect(imported.values['server_port'], '51820');
|
||||
expect(imported.values['mtu'], '1408');
|
||||
});
|
||||
|
||||
test('rejects configs without interface and peer sections', () {
|
||||
expect(
|
||||
() => WireguardConfigImport.fromConfigText('PrivateKey = private-key'),
|
||||
throwsFormatException,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import 'package:flutter_singbox_proxy/flutter_singbox_proxy.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:riverpod/riverpod.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_profiles.dart';
|
||||
import 'package:weblibre/features/user/data/database/definitions.drift.dart'
|
||||
show ProxyProfile;
|
||||
|
||||
void main() {
|
||||
group('proxyConnectionOptionsProvider', () {
|
||||
test('returns Tor and saved sing-box profiles', () async {
|
||||
final createdAt = DateTime(2026);
|
||||
final container = ProviderContainer(
|
||||
overrides: [
|
||||
singboxProxyProfilesRepositoryProvider.overrideWith(
|
||||
() => _FakeProfilesRepository([
|
||||
ProxyProfile(
|
||||
id: 'profile-1',
|
||||
name: 'Mullvad',
|
||||
type: SingboxProxyProfileType.customOutbound,
|
||||
configJson: '{"type":"socks"}',
|
||||
createdAt: createdAt,
|
||||
updatedAt: createdAt,
|
||||
),
|
||||
]),
|
||||
),
|
||||
],
|
||||
);
|
||||
addTearDown(container.dispose);
|
||||
|
||||
container.listen(
|
||||
singboxProxyProfilesRepositoryProvider,
|
||||
(_, _) {},
|
||||
fireImmediately: true,
|
||||
);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
final options = container.read(proxyConnectionOptionsProvider);
|
||||
|
||||
expect(options.map((option) => option.id), [
|
||||
const TorProxyConnectionId(),
|
||||
const SingboxProxyConnectionId('profile-1'),
|
||||
]);
|
||||
expect(options.map((option) => option.title), ['Tor', 'Mullvad']);
|
||||
});
|
||||
|
||||
test('labels unknown proxy ids explicitly', () {
|
||||
expect(
|
||||
proxyConnectionTitle(
|
||||
const [],
|
||||
const SingboxProxyConnectionId('missing'),
|
||||
),
|
||||
'Unknown proxy',
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
class _FakeProfilesRepository extends SingboxProxyProfilesRepository {
|
||||
final List<ProxyProfile> profiles;
|
||||
|
||||
_FakeProfilesRepository(this.profiles);
|
||||
|
||||
@override
|
||||
Stream<List<ProxyProfile>> build() => Stream.value(profiles);
|
||||
}
|
||||
+155
@@ -0,0 +1,155 @@
|
||||
import 'package:drift/native.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/data/database/functions/lexo_rank_functions.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/user/data/database/database.dart';
|
||||
import 'package:weblibre/features/user/data/database/definitions.drift.dart'
|
||||
show ProxyProfile;
|
||||
import 'package:weblibre/features/user/data/providers.dart';
|
||||
|
||||
void main() {
|
||||
test(
|
||||
'createProfile persists metadata and writes secrets separately',
|
||||
() async {
|
||||
final credentials = _FakeCredentialsRepository();
|
||||
final container = _container(credentials);
|
||||
addTearDown(container.dispose);
|
||||
|
||||
final repository = container.read(
|
||||
singboxProxyProfilesRepositoryProvider.notifier,
|
||||
);
|
||||
final created = await repository.createProfile(
|
||||
name: 'Mullvad WG',
|
||||
type: SingboxProxyProfileType.wireguard,
|
||||
configJson: '{"server":"vpn.example"}',
|
||||
secretJson: '{"private_key":"secret"}',
|
||||
);
|
||||
|
||||
final profiles = await repository.fetchProfiles();
|
||||
|
||||
expect(profiles, hasLength(1));
|
||||
expect(profiles.single.id, created.id);
|
||||
expect(profiles.single.name, 'Mullvad WG');
|
||||
expect(profiles.single.type, SingboxProxyProfileType.wireguard);
|
||||
expect(profiles.single.configJson, '{"server":"vpn.example"}');
|
||||
expect(credentials.writtenSecrets, {
|
||||
created.id: '{"private_key":"secret"}',
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'updateProfile updates metadata without touching secrets by default',
|
||||
() async {
|
||||
final credentials = _FakeCredentialsRepository();
|
||||
final container = _container(credentials);
|
||||
addTearDown(container.dispose);
|
||||
|
||||
final repository = container.read(
|
||||
singboxProxyProfilesRepositoryProvider.notifier,
|
||||
);
|
||||
final profile = _profile(id: 'profile-1', name: 'Original');
|
||||
await repository.updateProfile(profile);
|
||||
await repository.updateProfile(
|
||||
profile.copyWith(name: 'Updated', configJson: '{"type":"http"}'),
|
||||
);
|
||||
|
||||
final profiles = await repository.fetchProfiles();
|
||||
|
||||
expect(profiles, hasLength(1));
|
||||
expect(profiles.single.id, 'profile-1');
|
||||
expect(profiles.single.name, 'Updated');
|
||||
expect(profiles.single.configJson, '{"type":"http"}');
|
||||
expect(credentials.writtenSecrets, isEmpty);
|
||||
},
|
||||
);
|
||||
|
||||
test('updateProfile writes secrets only when requested', () async {
|
||||
final credentials = _FakeCredentialsRepository();
|
||||
final container = _container(credentials);
|
||||
addTearDown(container.dispose);
|
||||
|
||||
final repository = container.read(
|
||||
singboxProxyProfilesRepositoryProvider.notifier,
|
||||
);
|
||||
final profile = _profile(id: 'profile-1', name: 'SOCKS');
|
||||
|
||||
await repository.updateProfile(
|
||||
profile,
|
||||
secretJson: '{"password":"secret"}',
|
||||
);
|
||||
|
||||
expect(credentials.writtenSecrets, {'profile-1': '{"password":"secret"}'});
|
||||
});
|
||||
|
||||
test('deleteProfile removes metadata and deletes secrets', () async {
|
||||
final credentials = _FakeCredentialsRepository();
|
||||
final container = _container(credentials);
|
||||
addTearDown(container.dispose);
|
||||
|
||||
final repository = container.read(
|
||||
singboxProxyProfilesRepositoryProvider.notifier,
|
||||
);
|
||||
await repository.updateProfile(
|
||||
_profile(id: 'profile-1', name: 'SOCKS'),
|
||||
secretJson: '{"password":"secret"}',
|
||||
);
|
||||
|
||||
await repository.deleteProfile('profile-1');
|
||||
|
||||
expect(await repository.fetchProfiles(), isEmpty);
|
||||
expect(credentials.deletedSecretIds, ['profile-1']);
|
||||
});
|
||||
}
|
||||
|
||||
ProviderContainer _container(_FakeCredentialsRepository credentials) {
|
||||
final db = UserDatabase(
|
||||
NativeDatabase.memory(
|
||||
setup: (database) {
|
||||
registerLexorankFunctions(database);
|
||||
},
|
||||
),
|
||||
);
|
||||
addTearDown(db.close);
|
||||
|
||||
return ProviderContainer(
|
||||
overrides: [
|
||||
userDatabaseProvider.overrideWith((ref) => db),
|
||||
singboxProxyCredentialsRepositoryProvider.overrideWith(() => credentials),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
ProxyProfile _profile({required String id, required String name}) {
|
||||
final createdAt = DateTime(2026);
|
||||
|
||||
return ProxyProfile(
|
||||
id: id,
|
||||
name: name,
|
||||
type: SingboxProxyProfileType.socks,
|
||||
configJson: '{"type":"socks"}',
|
||||
createdAt: createdAt,
|
||||
updatedAt: createdAt,
|
||||
);
|
||||
}
|
||||
|
||||
class _FakeCredentialsRepository extends SingboxProxyCredentialsRepository {
|
||||
final writtenSecrets = <String, String?>{};
|
||||
final deletedSecretIds = <String>[];
|
||||
|
||||
@override
|
||||
Future<void> writeSecretJson(String profileId, String? secretJson) async {
|
||||
writtenSecrets[profileId] = secretJson;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> deleteSecretJson(String profileId) async {
|
||||
deletedSecretIds.add(profileId);
|
||||
}
|
||||
|
||||
@override
|
||||
void build() {}
|
||||
}
|
||||
+451
@@ -0,0 +1,451 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter_mozilla_components/flutter_mozilla_components.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/domain/repositories/container.dart';
|
||||
import 'package:weblibre/features/proxy/data/models/singbox_proxy_profile.dart';
|
||||
import 'package:weblibre/features/proxy/data/proxy_connection.dart';
|
||||
import 'package:weblibre/features/proxy/domain/repositories/container_proxy.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/domain/services/singbox_proxy_endpoint_sync.dart';
|
||||
import 'package:weblibre/features/user/data/database/definitions.drift.dart'
|
||||
show ProxyProfile;
|
||||
import 'package:weblibre/features/user/data/models/engine_settings.dart';
|
||||
import 'package:weblibre/features/user/data/models/proxy_routing_settings.dart';
|
||||
import 'package:weblibre/features/user/domain/repositories/engine_settings.dart';
|
||||
import 'package:weblibre/features/user/domain/repositories/proxy_routing_settings.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
test(
|
||||
'ensureProxyConnectionAvailable starts an assigned stopped profile',
|
||||
() async {
|
||||
final profile = _profile(id: 'profile-1', name: 'First');
|
||||
final client = _FakeSingboxProxyClient(_state(const []));
|
||||
final container = _container(
|
||||
client: client,
|
||||
profilesRepository: _FakeProfilesRepository([profile]),
|
||||
);
|
||||
addTearDown(container.dispose);
|
||||
|
||||
await container.read(singboxProxyRuntimeRepositoryProvider.future);
|
||||
final repository = container.read(
|
||||
singboxProxyRuntimeRepositoryProvider.notifier,
|
||||
);
|
||||
|
||||
await repository.ensureProxyConnectionAvailable(
|
||||
SingboxProxyConnectionId(profile.id),
|
||||
);
|
||||
await repository.ensureProxyConnectionAvailable(
|
||||
SingboxProxyConnectionId(profile.id),
|
||||
);
|
||||
|
||||
expect(client.startCalls, hasLength(1));
|
||||
expect(
|
||||
client.startCalls.single.map((runtimeProfile) => runtimeProfile.id),
|
||||
[profile.proxyConnectionId],
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test('startProfile preserves already-running profiles', () async {
|
||||
final profile1 = _profile(id: 'profile-1', name: 'First');
|
||||
final profile2 = _profile(id: 'profile-2', name: 'Second');
|
||||
final client = _FakeSingboxProxyClient(
|
||||
_state([_endpoint(profile1.proxyConnectionId, port: 12080)]),
|
||||
);
|
||||
final container = _container(
|
||||
client: client,
|
||||
profilesRepository: _FakeProfilesRepository([profile1, profile2]),
|
||||
);
|
||||
addTearDown(container.dispose);
|
||||
|
||||
await container.read(singboxProxyRuntimeRepositoryProvider.future);
|
||||
await container
|
||||
.read(singboxProxyRuntimeRepositoryProvider.notifier)
|
||||
.startProfile(profile2.id);
|
||||
|
||||
expect(client.startCalls, hasLength(1));
|
||||
expect(
|
||||
client.startCalls.single.map((profile) => profile.id),
|
||||
unorderedEquals([profile1.proxyConnectionId, profile2.proxyConnectionId]),
|
||||
);
|
||||
});
|
||||
|
||||
test(
|
||||
'concurrent startProfile calls preserve both requested profiles',
|
||||
() async {
|
||||
final profile1 = _profile(id: 'profile-1', name: 'First');
|
||||
final profile2 = _profile(id: 'profile-2', name: 'Second');
|
||||
final client = _FakeSingboxProxyClient(_state(const []));
|
||||
final container = _container(
|
||||
client: client,
|
||||
profilesRepository: _FakeProfilesRepository([profile1, profile2]),
|
||||
);
|
||||
addTearDown(container.dispose);
|
||||
|
||||
await container.read(singboxProxyRuntimeRepositoryProvider.future);
|
||||
final repository = container.read(
|
||||
singboxProxyRuntimeRepositoryProvider.notifier,
|
||||
);
|
||||
|
||||
await Future.wait([
|
||||
repository.startProfile(profile1.id),
|
||||
repository.startProfile(profile2.id),
|
||||
]);
|
||||
|
||||
expect(client.startCalls, hasLength(2));
|
||||
expect(
|
||||
client.startCalls.last.map((profile) => profile.id),
|
||||
unorderedEquals([
|
||||
profile1.proxyConnectionId,
|
||||
profile2.proxyConnectionId,
|
||||
]),
|
||||
);
|
||||
expect(
|
||||
container
|
||||
.read(singboxProxyRuntimeRepositoryProvider)
|
||||
.requireValue
|
||||
.endpoints
|
||||
.map((endpoint) => endpoint.profileId),
|
||||
unorderedEquals([
|
||||
profile1.proxyConnectionId,
|
||||
profile2.proxyConnectionId,
|
||||
]),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test('stopProfiles unregisters removed Gecko proxy endpoints', () async {
|
||||
final profile1 = _profile(id: 'profile-1', name: 'First');
|
||||
final profile2 = _profile(id: 'profile-2', name: 'Second');
|
||||
final client =
|
||||
_FakeSingboxProxyClient(
|
||||
_state([
|
||||
_endpoint(profile1.proxyConnectionId, port: 12080),
|
||||
_endpoint(profile2.proxyConnectionId, port: 12081),
|
||||
]),
|
||||
)
|
||||
..stateAfterStop = _state([
|
||||
_endpoint(profile1.proxyConnectionId, port: 12080),
|
||||
]);
|
||||
final containerProxyRepository = _FakeContainerProxyRepository();
|
||||
final container = _container(
|
||||
client: client,
|
||||
profilesRepository: _FakeProfilesRepository([profile1, profile2]),
|
||||
containerProxyRepository: containerProxyRepository,
|
||||
);
|
||||
addTearDown(container.dispose);
|
||||
|
||||
await container.read(singboxProxyRuntimeRepositoryProvider.future);
|
||||
await container
|
||||
.read(singboxProxyRuntimeRepositoryProvider.notifier)
|
||||
.stopProfiles([profile2.id]);
|
||||
await _drainSync();
|
||||
|
||||
expect(client.stopCalls, [profile2.proxyConnectionId]);
|
||||
expect(containerProxyRepository.removedProxyIds, [
|
||||
profile2.proxyConnectionId,
|
||||
]);
|
||||
});
|
||||
|
||||
test(
|
||||
'deleteProfile stops runtime and preserves persisted references',
|
||||
() async {
|
||||
final profile = _profile(id: 'profile-1', name: 'First');
|
||||
final client = _FakeSingboxProxyClient(
|
||||
_state([_endpoint(profile.proxyConnectionId, port: 12080)]),
|
||||
)..stateAfterStop = _state(const []);
|
||||
final profilesRepository = _FakeProfilesRepository([profile]);
|
||||
final containerRepository = _FakeContainerRepository();
|
||||
final routingSettingsRepository = _FakeProxyRoutingSettingsRepository(
|
||||
ProxyRoutingSettings(
|
||||
regularTabsMode: ProxyRegularTabRoutingMode.all,
|
||||
regularTabsProxyConnectionId: profile.proxyConnection,
|
||||
privateTabsProxyConnectionId: profile.proxyConnection,
|
||||
),
|
||||
);
|
||||
final container = _container(
|
||||
client: client,
|
||||
profilesRepository: profilesRepository,
|
||||
containerRepository: containerRepository,
|
||||
routingSettingsRepository: routingSettingsRepository,
|
||||
);
|
||||
addTearDown(container.dispose);
|
||||
|
||||
await container.read(singboxProxyRuntimeRepositoryProvider.future);
|
||||
await container
|
||||
.read(singboxProxyRuntimeRepositoryProvider.notifier)
|
||||
.deleteProfile(profile.id);
|
||||
|
||||
expect(client.stopCalls, [profile.proxyConnectionId]);
|
||||
expect(containerRepository.clearedProxyConnectionIds, isEmpty);
|
||||
final routingSettings = routingSettingsRepository.peekSettings();
|
||||
|
||||
expect(routingSettings, isNotNull);
|
||||
expect(routingSettings!.regularTabsMode, ProxyRegularTabRoutingMode.all);
|
||||
expect(
|
||||
routingSettings.regularTabsProxyConnectionId,
|
||||
profile.proxyConnection,
|
||||
);
|
||||
expect(
|
||||
routingSettings.privateTabsProxyConnectionId,
|
||||
profile.proxyConnection,
|
||||
);
|
||||
expect(profilesRepository.deletedProfileIds, [profile.id]);
|
||||
},
|
||||
);
|
||||
|
||||
test('runtime stream sync removes stale Gecko proxy registrations', () async {
|
||||
final profile1 = _profile(id: 'profile-1', name: 'First');
|
||||
final profile2 = _profile(id: 'profile-2', name: 'Second');
|
||||
final client = _FakeSingboxProxyClient(
|
||||
_state([
|
||||
_endpoint(profile1.proxyConnectionId, port: 12080),
|
||||
_endpoint(profile2.proxyConnectionId, port: 12081),
|
||||
]),
|
||||
);
|
||||
final containerProxyRepository = _FakeContainerProxyRepository();
|
||||
final container = _container(
|
||||
client: client,
|
||||
profilesRepository: _FakeProfilesRepository([profile1, profile2]),
|
||||
containerProxyRepository: containerProxyRepository,
|
||||
);
|
||||
addTearDown(container.dispose);
|
||||
|
||||
await container.read(singboxProxyRuntimeRepositoryProvider.future);
|
||||
client.emit(_state([_endpoint(profile2.proxyConnectionId, port: 12081)]));
|
||||
await _drainSync();
|
||||
|
||||
expect(containerProxyRepository.removedProxyIds, [
|
||||
profile1.proxyConnectionId,
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
ProviderContainer _container({
|
||||
required _FakeSingboxProxyClient client,
|
||||
required _FakeProfilesRepository profilesRepository,
|
||||
_FakeContainerProxyRepository? containerProxyRepository,
|
||||
_FakeContainerRepository? containerRepository,
|
||||
_FakeProxyRoutingSettingsRepository? routingSettingsRepository,
|
||||
}) {
|
||||
final container = ProviderContainer(
|
||||
overrides: [
|
||||
singboxProxyClientProvider.overrideWithValue(client),
|
||||
singboxProxyProfilesRepositoryProvider.overrideWith(
|
||||
() => profilesRepository,
|
||||
),
|
||||
singboxProxyCredentialsRepositoryProvider.overrideWith(
|
||||
_FakeCredentialsRepository.new,
|
||||
),
|
||||
engineSettingsRepositoryProvider.overrideWith(
|
||||
_FakeEngineSettingsRepository.new,
|
||||
),
|
||||
containerProxyRepositoryProvider.overrideWith(
|
||||
() => containerProxyRepository ?? _FakeContainerProxyRepository(),
|
||||
),
|
||||
containerRepositoryProvider.overrideWith(
|
||||
() => containerRepository ?? _FakeContainerRepository(),
|
||||
),
|
||||
proxyRoutingSettingsRepositoryProvider.overrideWith(
|
||||
() =>
|
||||
routingSettingsRepository ?? _FakeProxyRoutingSettingsRepository(),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
// Activate the endpoint sync notifier so runtime state changes propagate
|
||||
// into the fake container proxy repository. Production wires this in
|
||||
// main.dart; tests have to ask for it explicitly.
|
||||
container.read(singboxProxyEndpointSyncProvider);
|
||||
|
||||
return container;
|
||||
}
|
||||
|
||||
/// Drains all currently-pending microtasks/futures so the endpoint-sync
|
||||
/// `ref.listen` callback (chained off the runtime state stream listener)
|
||||
/// has time to apply its registrations against the fake Gecko repo.
|
||||
Future<void> _drainSync() => pumpEventQueue();
|
||||
|
||||
ProxyProfile _profile({required String id, required String name}) {
|
||||
final createdAt = DateTime(2026);
|
||||
|
||||
return ProxyProfile(
|
||||
id: id,
|
||||
name: name,
|
||||
type: SingboxProxyProfileType.customOutbound,
|
||||
configJson: '{"type":"socks"}',
|
||||
createdAt: createdAt,
|
||||
updatedAt: createdAt,
|
||||
);
|
||||
}
|
||||
|
||||
SingboxProxyRuntimeState _state(List<SingboxProxyRuntimeEndpoint> endpoints) {
|
||||
return SingboxProxyRuntimeState(
|
||||
status: endpoints.isEmpty
|
||||
? SingboxProxyRuntimeStatus.stopped
|
||||
: SingboxProxyRuntimeStatus.running,
|
||||
endpoints: endpoints,
|
||||
);
|
||||
}
|
||||
|
||||
SingboxProxyRuntimeEndpoint _endpoint(String profileId, {required int port}) {
|
||||
return SingboxProxyRuntimeEndpoint(
|
||||
profileId: profileId,
|
||||
host: '127.0.0.1',
|
||||
port: port,
|
||||
username: 'user-$port',
|
||||
password: 'pass-$port',
|
||||
);
|
||||
}
|
||||
|
||||
class _FakeSingboxProxyClient implements SingboxProxyClient {
|
||||
final _stateController =
|
||||
StreamController<SingboxProxyRuntimeState>.broadcast();
|
||||
final startCalls = <List<SingboxProxyProfile>>[];
|
||||
final stopCalls = <String>[];
|
||||
SingboxProxyRuntimeState _currentState;
|
||||
SingboxProxyRuntimeState? stateAfterStop;
|
||||
|
||||
_FakeSingboxProxyClient(this._currentState);
|
||||
|
||||
@override
|
||||
Stream<SingboxProxyRuntimeState> get stateStream => _stateController.stream;
|
||||
|
||||
@override
|
||||
Stream<SingboxProxyLogMessage> get logStream => const Stream.empty();
|
||||
|
||||
void emit(SingboxProxyRuntimeState state) {
|
||||
_currentState = state;
|
||||
_stateController.add(state);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<SingboxProxyRuntimeState> getState() async => _currentState;
|
||||
|
||||
@override
|
||||
Future<SingboxProxyRuntimeState> start(
|
||||
List<SingboxProxyProfile> profiles, {
|
||||
SingboxProxyRuntimeOptions? options,
|
||||
}) async {
|
||||
startCalls.add(profiles);
|
||||
return _currentState = _state(
|
||||
profiles.indexed
|
||||
.map((item) => _endpoint(item.$2.id, port: 12080 + item.$1))
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> stop(List<String> profileIds) async {
|
||||
stopCalls.addAll(profileIds);
|
||||
_currentState = stateAfterStop ?? _currentState;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> stopAll() async {
|
||||
_currentState = _state(const []);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String?> validateProfile(SingboxProxyProfile profile) async => null;
|
||||
|
||||
@override
|
||||
Future<SingboxProxyConfigResult> buildConfig(
|
||||
List<SingboxProxyProfile> profiles, {
|
||||
SingboxProxyRuntimeOptions? options,
|
||||
}) async {
|
||||
return SingboxProxyConfigResult(configJson: '{}', endpoints: const []);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> dispose() => _stateController.close();
|
||||
}
|
||||
|
||||
class _FakeProfilesRepository extends SingboxProxyProfilesRepository {
|
||||
final List<ProxyProfile> profiles;
|
||||
final deletedProfileIds = <String>[];
|
||||
|
||||
_FakeProfilesRepository(this.profiles);
|
||||
|
||||
@override
|
||||
Future<List<ProxyProfile>> fetchProfiles() async => profiles;
|
||||
|
||||
@override
|
||||
Future<void> deleteProfile(String profileId) async {
|
||||
deletedProfileIds.add(profileId);
|
||||
}
|
||||
|
||||
@override
|
||||
Stream<List<ProxyProfile>> build() => Stream.value(profiles);
|
||||
}
|
||||
|
||||
class _FakeCredentialsRepository extends SingboxProxyCredentialsRepository {
|
||||
@override
|
||||
Future<String?> readSecretJson(String profileId) async => null;
|
||||
|
||||
@override
|
||||
void build() {}
|
||||
}
|
||||
|
||||
class _FakeEngineSettingsRepository extends EngineSettingsRepository {
|
||||
final settings = EngineSettings.withDefaults();
|
||||
|
||||
@override
|
||||
Future<EngineSettings> fetchSettings() async => settings;
|
||||
|
||||
@override
|
||||
Stream<EngineSettings> build() => Stream.value(settings);
|
||||
}
|
||||
|
||||
class _FakeContainerProxyRepository extends ContainerProxyRepository {
|
||||
final upsertedProxies = <GeckoProxySettings>[];
|
||||
final removedProxyIds = <String>[];
|
||||
|
||||
@override
|
||||
Future<void> upsertProxy(GeckoProxySettings proxy) async {
|
||||
upsertedProxies.add(proxy);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> removeProxy(String proxyId) async {
|
||||
removedProxyIds.add(proxyId);
|
||||
}
|
||||
|
||||
@override
|
||||
void build() {}
|
||||
}
|
||||
|
||||
class _FakeContainerRepository extends ContainerRepository {
|
||||
final clearedProxyConnectionIds = <ProxyConnectionId>[];
|
||||
|
||||
@override
|
||||
Future<void> clearProxyConnectionAssignments(
|
||||
ProxyConnectionId proxyConnectionId,
|
||||
) async {
|
||||
clearedProxyConnectionIds.add(proxyConnectionId);
|
||||
}
|
||||
|
||||
@override
|
||||
void build() {}
|
||||
}
|
||||
|
||||
class _FakeProxyRoutingSettingsRepository
|
||||
extends ProxyRoutingSettingsRepository {
|
||||
ProxyRoutingSettings? _settings;
|
||||
|
||||
_FakeProxyRoutingSettingsRepository([this._settings]);
|
||||
|
||||
ProxyRoutingSettings? peekSettings() => _settings;
|
||||
|
||||
@override
|
||||
Stream<ProxyRoutingSettings> build() {
|
||||
return Stream.value(_settings ?? ProxyRoutingSettings.withDefaults());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:weblibre/features/proxy/domain/services/dns_config_resolver.dart';
|
||||
import 'package:weblibre/features/user/data/models/proxy_dns_override.dart';
|
||||
|
||||
void main() {
|
||||
test('mirrors browser DoH through running profiles automatically', () {
|
||||
final config = buildDnsConfig(
|
||||
overridesByProfileId: const {'profile-1': null},
|
||||
runningProfileIds: const {'profile-1'},
|
||||
browserDohUrl: 'https://dns.example/dns-query',
|
||||
);
|
||||
|
||||
expect(config, isNotNull);
|
||||
expect(config!.finalServerTag, 'browser-doh');
|
||||
expect(config.servers, hasLength(2));
|
||||
expect(config.servers.first.detourTag, isNull);
|
||||
expect(config.servers.last.tag, 'browser-doh-profile-1');
|
||||
expect(config.servers.last.detourTag, 'out-singbox_profile-1');
|
||||
expect(config.servers.last.matchInbounds, ['in-singbox_profile-1']);
|
||||
});
|
||||
|
||||
test('profile override replaces automatic browser DoH mirror', () {
|
||||
final config = buildDnsConfig(
|
||||
overridesByProfileId: {
|
||||
'profile-1': ProxyDnsOverride(remoteServerAddress: '10.0.0.53'),
|
||||
},
|
||||
runningProfileIds: const {'profile-1'},
|
||||
browserDohUrl: 'https://dns.example/dns-query',
|
||||
);
|
||||
|
||||
expect(config, isNotNull);
|
||||
expect(config!.servers.map((s) => s.tag), [
|
||||
'browser-doh',
|
||||
'override-profile-1',
|
||||
]);
|
||||
expect(config.servers.last.address, '10.0.0.53');
|
||||
expect(config.servers.last.detourTag, 'out-singbox_profile-1');
|
||||
expect(config.servers.last.matchInbounds, ['in-singbox_profile-1']);
|
||||
});
|
||||
|
||||
test('returns null when there is no browser DoH or override', () {
|
||||
final config = buildDnsConfig(
|
||||
overridesByProfileId: const {'profile-1': null},
|
||||
runningProfileIds: const {'profile-1'},
|
||||
browserDohUrl: null,
|
||||
);
|
||||
|
||||
expect(config, isNull);
|
||||
});
|
||||
|
||||
test('auto domain strategy emits sing-box as-is strategy', () {
|
||||
expect(ProxyDnsDomainStrategy.auto.singboxValue, isEmpty);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:weblibre/features/proxy/domain/services/dns_config_resolver.dart';
|
||||
|
||||
/// The Dart and Kotlin sides of the sing-box config builder must produce the
|
||||
/// same `out-`/`in-` tags. These cases mirror `SingboxTagFormatTest.kt` —
|
||||
/// both must update together if this format ever changes.
|
||||
void main() {
|
||||
group('singbox tag format', () {
|
||||
test('outboundTag is prefixed and sanitized', () {
|
||||
expect(singboxOutboundTag('singbox:foo-bar'), 'out-singbox_foo-bar');
|
||||
});
|
||||
|
||||
test('inboundTag is prefixed and sanitized', () {
|
||||
expect(singboxInboundTag('singbox:foo-bar'), 'in-singbox_foo-bar');
|
||||
});
|
||||
|
||||
test('sanitizeTag preserves alphanumeric, dots, dashes, underscores', () {
|
||||
expect(singboxSanitizeTag('Abc_123.x-Y'), 'Abc_123.x-Y');
|
||||
});
|
||||
|
||||
test('sanitizeTag replaces spaces, slashes, colons', () {
|
||||
expect(singboxSanitizeTag('a:b c/d\\e'), 'a_b_c_d_e');
|
||||
});
|
||||
});
|
||||
}
|
||||
+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);
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -24,7 +24,7 @@ import 'dart:typed_data';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:riverpod/experimental/persist.dart';
|
||||
import 'package:riverpod/riverpod.dart';
|
||||
import 'package:search_backend/search_backend.dart';
|
||||
import 'package:search_protocol/search_protocol.dart';
|
||||
import 'package:weblibre/features/search_credits/domain/controllers/search_token_issuance_controller.dart';
|
||||
import 'package:weblibre/features/user/data/providers.dart';
|
||||
import 'package:weblibre/features/web_search/domain/controllers/search_controller.dart';
|
||||
|
||||
@@ -125,23 +125,25 @@ void main() {
|
||||
expect(decoded['url'], contains('t='));
|
||||
});
|
||||
|
||||
test('err=1 starts the loader in the error pane with a retry button',
|
||||
() async {
|
||||
final url = await server.loaderUrl(
|
||||
tabId: 'tab-9',
|
||||
captureId: 'failed9',
|
||||
error: true,
|
||||
);
|
||||
final res = await _get(url);
|
||||
expect(res.statusCode, 200);
|
||||
// Retry is now a JS-driven button, not an HTML form — assert the
|
||||
// shell starts in error mode (error pane visible, pending hidden)
|
||||
// and that the button + JS retry endpoint string are present.
|
||||
expect(res.body, contains('id="retry"'));
|
||||
expect(res.body, contains('id="error" class="error"'));
|
||||
expect(res.body, contains('id="pending" class="hidden"'));
|
||||
expect(res.body, contains('/loader/retry?tab='));
|
||||
});
|
||||
test(
|
||||
'err=1 starts the loader in the error pane with a retry button',
|
||||
() async {
|
||||
final url = await server.loaderUrl(
|
||||
tabId: 'tab-9',
|
||||
captureId: 'failed9',
|
||||
error: true,
|
||||
);
|
||||
final res = await _get(url);
|
||||
expect(res.statusCode, 200);
|
||||
// Retry is now a JS-driven button, not an HTML form — assert the
|
||||
// shell starts in error mode (error pane visible, pending hidden)
|
||||
// and that the button + JS retry endpoint string are present.
|
||||
expect(res.body, contains('id="retry"'));
|
||||
expect(res.body, contains('id="error" class="error"'));
|
||||
expect(res.body, contains('id="pending" class="hidden"'));
|
||||
expect(res.body, contains('/loader/retry?tab='));
|
||||
},
|
||||
);
|
||||
|
||||
test('loader with invalid capture id returns 404', () async {
|
||||
final port = await server.ensureStarted();
|
||||
@@ -174,9 +176,7 @@ void main() {
|
||||
// Start the long-poll while nothing has been published yet — it
|
||||
// should block on the per-id completer, not busy-poll the filesystem.
|
||||
final pollFuture = _get(
|
||||
Uri.parse(
|
||||
'http://127.0.0.1:$port/loader/wait?tab=t&capture=signaled',
|
||||
),
|
||||
Uri.parse('http://127.0.0.1:$port/loader/wait?tab=t&capture=signaled'),
|
||||
);
|
||||
// Race-free: publish only after the request hit the handler. A short
|
||||
// microtask flush gives the handler time to register its waiter.
|
||||
@@ -192,23 +192,25 @@ void main() {
|
||||
});
|
||||
|
||||
group('retry route', () {
|
||||
test('emits a RetryRequest on retryRequests stream and returns 204',
|
||||
() async {
|
||||
final port = await server.ensureStarted();
|
||||
final retryFuture = server.retryRequests.first;
|
||||
test(
|
||||
'emits a RetryRequest on retryRequests stream and returns 204',
|
||||
() async {
|
||||
final port = await server.ensureStarted();
|
||||
final retryFuture = server.retryRequests.first;
|
||||
|
||||
final res = await _post(
|
||||
Uri.parse(
|
||||
'http://127.0.0.1:$port/loader/retry?tab=tab-42&capture=cap42',
|
||||
),
|
||||
);
|
||||
// 204 No Content — the JS loader re-polls; a body would be wasted.
|
||||
expect(res.statusCode, 204);
|
||||
final res = await _post(
|
||||
Uri.parse(
|
||||
'http://127.0.0.1:$port/loader/retry?tab=tab-42&capture=cap42',
|
||||
),
|
||||
);
|
||||
// 204 No Content — the JS loader re-polls; a body would be wasted.
|
||||
expect(res.statusCode, 204);
|
||||
|
||||
final req = await retryFuture.timeout(const Duration(seconds: 2));
|
||||
expect(req.tabId, 'tab-42');
|
||||
expect(req.captureId, 'cap42');
|
||||
});
|
||||
final req = await retryFuture.timeout(const Duration(seconds: 2));
|
||||
expect(req.tabId, 'tab-42');
|
||||
expect(req.captureId, 'cap42');
|
||||
},
|
||||
);
|
||||
|
||||
test('retry rejects GET with 405', () async {
|
||||
final port = await server.ensureStarted();
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:search_backend/search_backend.dart';
|
||||
import 'package:search_protocol/search_protocol.dart';
|
||||
import 'package:weblibre/features/geckoview/domain/entities/tab_container_selection.dart';
|
||||
import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_mode.dart';
|
||||
import 'package:weblibre/features/user/domain/providers.dart';
|
||||
@@ -79,14 +79,14 @@ void main() {
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
|
||||
expect(find.text('Heading'), findsOneWidget);
|
||||
expect(find.text('Body copy'), findsOneWidget);
|
||||
expect(find.text('Fabian'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.byTooltip('Open in browser'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.pump();
|
||||
|
||||
expect(opener.openedUris, [Uri.parse(url)]);
|
||||
});
|
||||
|
||||
+17
-9
@@ -23,7 +23,9 @@ import 'dart:typed_data';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:search_backend/search_backend.dart';
|
||||
import 'package:search_protocol/search_protocol.dart';
|
||||
import 'package:weblibre/features/search_credits/data/models/web_search_settings.dart';
|
||||
import 'package:weblibre/features/search_credits/domain/repositories/web_search_settings.dart';
|
||||
import 'package:weblibre/features/user/domain/providers.dart';
|
||||
import 'package:weblibre/features/web_search/domain/controllers/search_controller.dart';
|
||||
import 'package:weblibre/features/web_search/presentation/widgets/search_result_card.dart';
|
||||
@@ -39,12 +41,15 @@ void main() {
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
...webSearchTestOverrides(),
|
||||
metaSearchControllerProvider.overrideWithValue(
|
||||
MetaSearchState(status: WebSearchStatus.ready),
|
||||
),
|
||||
watchCachedIconBytesProvider.overrideWith((ref, origin) {
|
||||
return Stream.value(null);
|
||||
}),
|
||||
metaSearchControllerProvider.overrideWithValue(
|
||||
const MetaSearchState(status: WebSearchStatus.ready),
|
||||
),
|
||||
webSearchSettingsControllerProvider.overrideWithValue(
|
||||
WebSearchSettings.withDefaults(),
|
||||
),
|
||||
],
|
||||
child: MaterialApp(
|
||||
home: Scaffold(
|
||||
@@ -67,7 +72,7 @@ void main() {
|
||||
);
|
||||
|
||||
await tester.tap(find.text('LensAI result'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.pump();
|
||||
|
||||
expect(openedUris, [Uri.parse(url)]);
|
||||
});
|
||||
@@ -84,15 +89,18 @@ void main() {
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
...webSearchTestOverrides(),
|
||||
watchCachedIconBytesProvider.overrideWith((ref, origin) {
|
||||
return Stream.value(null);
|
||||
}),
|
||||
metaSearchControllerProvider.overrideWithValue(
|
||||
MetaSearchState(
|
||||
status: WebSearchStatus.ready,
|
||||
imagesByUrl: {thumbnailUrl: Uint8List.fromList(pngBytes)},
|
||||
),
|
||||
),
|
||||
watchCachedIconBytesProvider.overrideWith((ref, origin) {
|
||||
return Stream.value(null);
|
||||
}),
|
||||
webSearchSettingsControllerProvider.overrideWithValue(
|
||||
WebSearchSettings.withDefaults(),
|
||||
),
|
||||
],
|
||||
child: MaterialApp(
|
||||
home: Scaffold(
|
||||
@@ -114,7 +122,7 @@ void main() {
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pumpAndSettle();
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
|
||||
expect(find.byType(Image), findsOneWidget);
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user