d780cb863d
Changelog: The elevated Core only answers its owner
1177 lines
38 KiB
Dart
1177 lines
38 KiB
Dart
import 'dart:async';
|
|
import 'dart:io';
|
|
|
|
import 'package:fl_clash/common/common.dart';
|
|
import 'package:fl_clash/core/controller.dart';
|
|
import 'package:fl_clash/core/interface.dart';
|
|
import 'package:fl_clash/enum/enum.dart';
|
|
import 'package:fl_clash/l10n/l10n.dart';
|
|
import 'package:fl_clash/models/models.dart';
|
|
import 'package:fl_clash/providers/action.dart';
|
|
import 'package:fl_clash/providers/app.dart';
|
|
import 'package:fl_clash/providers/config.dart';
|
|
import 'package:fl_clash/providers/core.dart';
|
|
import 'package:fl_clash/providers/database.dart';
|
|
import 'package:fl_clash/providers/state.dart';
|
|
import 'package:fl_clash/state.dart';
|
|
import 'package:flutter/widgets.dart';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:mocktail/mocktail.dart';
|
|
import 'package:package_info_plus/package_info_plus.dart';
|
|
import 'package:path_provider_platform_interface/path_provider_platform_interface.dart';
|
|
import 'package:riverpod/riverpod.dart';
|
|
import 'package:yaml/yaml.dart';
|
|
|
|
import '../helpers/test_profiles.dart';
|
|
|
|
class _MockCoreHandlerInterface extends Mock implements CoreHandlerInterface {}
|
|
|
|
// checkAndUpdateAndCopy checks the file system before it refreshes, so its
|
|
// failure tests need appPath to resolve to a real, writable directory.
|
|
class _FakePathProvider extends PathProviderPlatform {
|
|
final String root;
|
|
|
|
_FakePathProvider(this.root);
|
|
|
|
@override
|
|
Future<String?> getTemporaryPath() async => root;
|
|
|
|
@override
|
|
Future<String?> getApplicationSupportPath() async => root;
|
|
|
|
@override
|
|
Future<String?> getApplicationCachePath() async => root;
|
|
}
|
|
|
|
class _ListenerHandoffFailureSetupAction extends SetupAction {
|
|
final List<bool> coreRunningCalls = [];
|
|
|
|
@override
|
|
Future<bool> setCoreRunning(bool running) async {
|
|
coreRunningCalls.add(running);
|
|
if (running) {
|
|
throw StateError('listener handoff failed');
|
|
}
|
|
return true;
|
|
}
|
|
}
|
|
|
|
class _MessageFailureSetupAction extends SetupAction {
|
|
final List<bool> coreRunningCalls = [];
|
|
|
|
@override
|
|
Future<bool> setCoreRunning(bool running) async {
|
|
coreRunningCalls.add(running);
|
|
return true;
|
|
}
|
|
}
|
|
|
|
class TestCommonAction extends CommonAction {
|
|
int trafficUpdates = 0;
|
|
|
|
@override
|
|
Future<void> updateTraffic() async {
|
|
trafficUpdates++;
|
|
}
|
|
}
|
|
|
|
class TestSetupAction extends SetupAction {
|
|
final List<bool> coreRunningCalls = [];
|
|
final List<Completer<void>> pendingCoreCalls = [];
|
|
int trafficResets = 0;
|
|
int applyProfileCalls = 0;
|
|
bool blockCoreCalls = false;
|
|
Error? coreRunningError;
|
|
int authorizeCalls = 0;
|
|
AuthorizeCode authorizeResult = AuthorizeCode.none;
|
|
|
|
@override
|
|
Future<AuthorizeCode> authorizeCore() async {
|
|
authorizeCalls++;
|
|
return authorizeResult;
|
|
}
|
|
|
|
@override
|
|
Future<bool> setCoreRunning(bool running) async {
|
|
coreRunningCalls.add(running);
|
|
if (blockCoreCalls) {
|
|
final gate = Completer<void>();
|
|
pendingCoreCalls.add(gate);
|
|
await gate.future;
|
|
}
|
|
final error = coreRunningError;
|
|
if (error != null) {
|
|
throw error;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
@override
|
|
void resetCoreTraffic() => trafficResets++;
|
|
|
|
@override
|
|
Future<bool> applyProfile({
|
|
bool silence = false,
|
|
bool force = false,
|
|
Future<void> Function()? preloadInvoke,
|
|
}) async {
|
|
applyProfileCalls++;
|
|
await preloadInvoke?.call();
|
|
return true;
|
|
}
|
|
}
|
|
|
|
void main() {
|
|
TestWidgetsFlutterBinding.ensureInitialized();
|
|
setUpAll(() {
|
|
registerFallbackValue(const SetupParams(selectedMap: {}, testUrl: ''));
|
|
registerFallbackValue(
|
|
const UpdateParams(
|
|
tun: Tun(),
|
|
mixedPort: 7890,
|
|
allowLan: false,
|
|
findProcessMode: FindProcessMode.off,
|
|
mode: Mode.rule,
|
|
logLevel: LogLevel.info,
|
|
ipv6: false,
|
|
tcpConcurrent: false,
|
|
externalController: ExternalControllerStatus.close,
|
|
unifiedDelay: false,
|
|
),
|
|
);
|
|
});
|
|
|
|
late TestSetupAction action;
|
|
late ProviderContainer container;
|
|
|
|
setUp(() {
|
|
action = TestSetupAction();
|
|
container = ProviderContainer(
|
|
overrides: [
|
|
profilesProvider.overrideWith(TestProfiles.new),
|
|
setupActionProvider.overrideWith(() => action),
|
|
commonActionProvider.overrideWith(TestCommonAction.new),
|
|
],
|
|
);
|
|
globalState.container = container;
|
|
globalState.needInitStatus = true;
|
|
container.read(setupActionProvider.notifier);
|
|
});
|
|
|
|
tearDown(() async {
|
|
action.blockCoreCalls = false;
|
|
action.coreRunningError = null;
|
|
for (final gate in action.pendingCoreCalls) {
|
|
if (!gate.isCompleted) {
|
|
gate.complete();
|
|
}
|
|
}
|
|
await container.read(setupActionProvider.notifier).setRunning(false);
|
|
container.dispose();
|
|
globalState.needInitStatus = true;
|
|
});
|
|
|
|
void markInitialized() {
|
|
container.read(initProvider.notifier).value = true;
|
|
}
|
|
|
|
group('setRunning gating', () {
|
|
test('ignores a start request before initialization completes', () async {
|
|
await container.read(setupActionProvider.notifier).setRunning(true);
|
|
|
|
expect(action.coreRunningCalls, isEmpty);
|
|
expect(container.read(runTimeProvider), isNull);
|
|
});
|
|
|
|
test('an initialize request bypasses the init gate', () async {
|
|
await container
|
|
.read(setupActionProvider.notifier)
|
|
.setRunning(true, initialize: true);
|
|
|
|
expect(action.coreRunningCalls, [true]);
|
|
expect(action.applyProfileCalls, 1);
|
|
expect(globalState.needInitStatus, isFalse);
|
|
});
|
|
|
|
test('safe mode runs the profile but never touches the listener', () async {
|
|
final scopedAction = TestSetupAction();
|
|
final scoped = ProviderContainer(
|
|
overrides: [
|
|
profilesProvider.overrideWith(TestProfiles.new),
|
|
setupActionProvider.overrideWith(() => scopedAction),
|
|
commonActionProvider.overrideWith(TestCommonAction.new),
|
|
safeModeProvider.overrideWithValue(true),
|
|
],
|
|
);
|
|
addTearDown(scoped.dispose);
|
|
globalState.container = scoped;
|
|
scoped.read(setupActionProvider.notifier);
|
|
|
|
await scoped
|
|
.read(setupActionProvider.notifier)
|
|
.setRunning(true, initialize: true);
|
|
|
|
expect(scopedAction.coreRunningCalls, isEmpty);
|
|
expect(scopedAction.applyProfileCalls, 1);
|
|
expect(scoped.read(isStartProvider), isTrue);
|
|
|
|
await scoped.read(setupActionProvider.notifier).setRunning(false);
|
|
|
|
expect(scopedAction.coreRunningCalls, isEmpty);
|
|
expect(scoped.read(isStartProvider), isFalse);
|
|
});
|
|
|
|
test('starts the core once initialization is done', () async {
|
|
markInitialized();
|
|
|
|
await container.read(setupActionProvider.notifier).setRunning(true);
|
|
|
|
expect(action.coreRunningCalls, [true]);
|
|
expect(container.read(runTimeProvider), isNotNull);
|
|
});
|
|
|
|
test('a stop request is never gated on initialization', () async {
|
|
await container.read(setupActionProvider.notifier).setRunning(false);
|
|
|
|
expect(action.coreRunningCalls, [false]);
|
|
});
|
|
});
|
|
|
|
group('run failures', () {
|
|
test('a start the core rejects stops reporting a run time', () async {
|
|
markInitialized();
|
|
action.coreRunningError = StateError('start failed');
|
|
|
|
await expectLater(
|
|
container.read(setupActionProvider.notifier).setRunning(true),
|
|
throwsStateError,
|
|
);
|
|
|
|
expect(container.read(runTimeProvider), isNull);
|
|
});
|
|
|
|
test(
|
|
'a stop the core rejects keeps the run time it started with',
|
|
() async {
|
|
markInitialized();
|
|
await container.read(setupActionProvider.notifier).setRunning(true);
|
|
action.coreRunningError = StateError('stop failed');
|
|
|
|
await expectLater(
|
|
container.read(setupActionProvider.notifier).setRunning(false),
|
|
throwsStateError,
|
|
);
|
|
|
|
expect(container.read(runTimeProvider), isNotNull);
|
|
},
|
|
);
|
|
});
|
|
|
|
group('stop cleanup', () {
|
|
test('resets traffic counters', () async {
|
|
markInitialized();
|
|
await container.read(setupActionProvider.notifier).setRunning(true);
|
|
container.read(totalTrafficProvider.notifier).value = const Traffic(
|
|
up: 10,
|
|
down: 20,
|
|
);
|
|
|
|
await container.read(setupActionProvider.notifier).setRunning(false);
|
|
|
|
expect(action.trafficResets, 1);
|
|
expect(container.read(trafficsProvider).list, isEmpty);
|
|
expect(container.read(totalTrafficProvider), const Traffic());
|
|
expect(container.read(runTimeProvider), isNull);
|
|
});
|
|
});
|
|
|
|
group('run time ticker', () {
|
|
late ProviderContainer scoped;
|
|
|
|
// Built inside each test, on its fake clock: futures made there never
|
|
// settle in the shared tearDown.
|
|
SetupAction buildScoped() {
|
|
scoped = ProviderContainer(
|
|
overrides: [
|
|
profilesProvider.overrideWith(TestProfiles.new),
|
|
setupActionProvider.overrideWith(TestSetupAction.new),
|
|
commonActionProvider.overrideWith(TestCommonAction.new),
|
|
],
|
|
);
|
|
addTearDown(scoped.dispose);
|
|
globalState.container = scoped;
|
|
scoped
|
|
.read(appSettingProvider.notifier)
|
|
.update((state) => state.copyWith(showTrayTitle: false));
|
|
scoped.read(initProvider.notifier).value = true;
|
|
return scoped.read(setupActionProvider.notifier);
|
|
}
|
|
|
|
int trafficUpdates() =>
|
|
(scoped.read(commonActionProvider.notifier) as TestCommonAction)
|
|
.trafficUpdates;
|
|
|
|
void setVisible(bool visible) =>
|
|
scoped.read(appVisibleProvider.notifier).value = visible;
|
|
|
|
testWidgets('reads traffic only while the app is visible', (tester) async {
|
|
final setup = buildScoped();
|
|
await setup.setRunning(true);
|
|
expect(trafficUpdates(), 1);
|
|
|
|
await tester.pump(const Duration(seconds: 2));
|
|
expect(trafficUpdates(), 3);
|
|
|
|
setVisible(false);
|
|
await tester.pump(const Duration(seconds: 5));
|
|
expect(trafficUpdates(), 3);
|
|
expect(scoped.read(isStartProvider), isTrue);
|
|
|
|
setVisible(true);
|
|
await tester.pump();
|
|
expect(trafficUpdates(), 4);
|
|
await tester.pump(const Duration(seconds: 1));
|
|
expect(trafficUpdates(), 5);
|
|
|
|
await setup.setRunning(false);
|
|
});
|
|
|
|
testWidgets('a start while hidden waits for the app to be shown', (
|
|
tester,
|
|
) async {
|
|
final setup = buildScoped();
|
|
setVisible(false);
|
|
await setup.setRunning(true);
|
|
await tester.pump(const Duration(seconds: 3));
|
|
|
|
expect(scoped.read(runTimeProvider), isNotNull);
|
|
expect(trafficUpdates(), 0);
|
|
|
|
setVisible(true);
|
|
await tester.pump();
|
|
expect(trafficUpdates(), 1);
|
|
|
|
await setup.setRunning(false);
|
|
});
|
|
|
|
testWidgets('the macOS menu bar title keeps traffic coming while hidden', (
|
|
tester,
|
|
) async {
|
|
final setup = buildScoped();
|
|
setVisible(false);
|
|
await setup.setRunning(true);
|
|
await tester.pump(const Duration(seconds: 2));
|
|
expect(trafficUpdates(), 0);
|
|
|
|
scoped
|
|
.read(appSettingProvider.notifier)
|
|
.update((state) => state.copyWith(showTrayTitle: true));
|
|
await tester.pump(const Duration(seconds: 2));
|
|
|
|
expect(trafficUpdates(), system.isMacOS ? 3 : 0);
|
|
|
|
await setup.setRunning(false);
|
|
});
|
|
});
|
|
|
|
group('suspend', () {
|
|
test('skips starting the core on an excluded SSID', () async {
|
|
container.dispose();
|
|
action = TestSetupAction();
|
|
container = ProviderContainer(
|
|
overrides: [
|
|
profilesProvider.overrideWith(TestProfiles.new),
|
|
setupActionProvider.overrideWith(() => action),
|
|
commonActionProvider.overrideWith(TestCommonAction.new),
|
|
excludeSSIDsProvider.overrideWithValue(const ['Office Wi-Fi']),
|
|
],
|
|
);
|
|
globalState.container = container;
|
|
container.read(initProvider.notifier).value = true;
|
|
container.read(currentSSIDProvider.notifier).value = 'Office Wi-Fi';
|
|
|
|
await container.read(setupActionProvider.notifier).setRunning(true);
|
|
|
|
expect(action.coreRunningCalls, isEmpty);
|
|
});
|
|
|
|
test('still stops the core on an excluded SSID', () async {
|
|
container.dispose();
|
|
action = TestSetupAction();
|
|
container = ProviderContainer(
|
|
overrides: [
|
|
profilesProvider.overrideWith(TestProfiles.new),
|
|
setupActionProvider.overrideWith(() => action),
|
|
commonActionProvider.overrideWith(TestCommonAction.new),
|
|
excludeSSIDsProvider.overrideWithValue(const ['Office Wi-Fi']),
|
|
],
|
|
);
|
|
globalState.container = container;
|
|
container.read(currentSSIDProvider.notifier).value = 'Office Wi-Fi';
|
|
|
|
await container.read(setupActionProvider.notifier).setRunning(false);
|
|
|
|
expect(action.coreRunningCalls, [false]);
|
|
});
|
|
});
|
|
|
|
group('latest-intent arbitration', () {
|
|
test('a superseded stop does not run the post-stop cleanup', () async {
|
|
markInitialized();
|
|
final notifier = container.read(setupActionProvider.notifier);
|
|
action.blockCoreCalls = true;
|
|
|
|
final stopping = notifier.setRunning(false);
|
|
await Future<void>.delayed(Duration.zero);
|
|
final starting = notifier.setRunning(true);
|
|
await Future<void>.delayed(Duration.zero);
|
|
|
|
action.blockCoreCalls = false;
|
|
for (final gate in action.pendingCoreCalls) {
|
|
if (!gate.isCompleted) {
|
|
gate.complete();
|
|
}
|
|
}
|
|
await Future.wait([stopping, starting]);
|
|
|
|
expect(action.coreRunningCalls, [false, true]);
|
|
expect(action.trafficResets, 0);
|
|
expect(container.read(runTimeProvider), isNotNull);
|
|
});
|
|
|
|
test('the newest request wins the local running state', () async {
|
|
markInitialized();
|
|
final notifier = container.read(setupActionProvider.notifier);
|
|
|
|
await notifier.setRunning(true);
|
|
expect(container.read(runTimeProvider), isNotNull);
|
|
|
|
await notifier.setRunning(false);
|
|
expect(container.read(runTimeProvider), isNull);
|
|
|
|
await notifier.setRunning(true);
|
|
expect(container.read(runTimeProvider), isNotNull);
|
|
expect(action.coreRunningCalls, [true, false, true]);
|
|
});
|
|
});
|
|
|
|
group('initStatus', () {
|
|
test('is a no-op once the status has already been initialized', () async {
|
|
globalState.needInitStatus = false;
|
|
|
|
await container.read(setupActionProvider.notifier).initStatus();
|
|
|
|
expect(action.coreRunningCalls, isEmpty);
|
|
expect(action.applyProfileCalls, 0);
|
|
});
|
|
|
|
test('starts the core when autoRun is enabled', () async {
|
|
container.read(appSettingProvider.notifier).value = const AppSettingProps(
|
|
autoRun: true,
|
|
);
|
|
|
|
await container.read(setupActionProvider.notifier).initStatus();
|
|
|
|
expect(action.coreRunningCalls, [true]);
|
|
expect(globalState.needInitStatus, isFalse);
|
|
});
|
|
|
|
test('only applies the profile when autoRun is disabled', () async {
|
|
container.read(appSettingProvider.notifier).value = const AppSettingProps(
|
|
autoRun: false,
|
|
);
|
|
|
|
await container.read(setupActionProvider.notifier).initStatus();
|
|
|
|
expect(action.coreRunningCalls, isEmpty);
|
|
expect(action.applyProfileCalls, 1);
|
|
});
|
|
});
|
|
|
|
group('requestAdmin', () {
|
|
test('never asks for authorization while tun is disabled', () async {
|
|
expect(await action.requestAdmin(false), isTrue);
|
|
|
|
expect(action.authorizeCalls, 0);
|
|
expect(
|
|
container.read(authorizedTunEnableProvider),
|
|
TunAuthorizationState.none,
|
|
);
|
|
});
|
|
|
|
test('safe mode never asks, whatever the tun setting says', () async {
|
|
final scopedAction = TestSetupAction();
|
|
final scoped = ProviderContainer(
|
|
overrides: [
|
|
profilesProvider.overrideWith(TestProfiles.new),
|
|
setupActionProvider.overrideWith(() => scopedAction),
|
|
safeModeProvider.overrideWithValue(true),
|
|
],
|
|
);
|
|
addTearDown(scoped.dispose);
|
|
scoped.read(setupActionProvider.notifier);
|
|
|
|
expect(await scopedAction.requestAdmin(true), isTrue);
|
|
expect(scopedAction.authorizeCalls, 0);
|
|
expect(
|
|
scoped.read(authorizedTunEnableProvider),
|
|
TunAuthorizationState.none,
|
|
);
|
|
});
|
|
|
|
test('does not ask again once the state left none', () async {
|
|
container.read(authorizedTunEnableProvider.notifier).value =
|
|
TunAuthorizationState.authorized;
|
|
|
|
expect(await action.requestAdmin(true), isTrue);
|
|
expect(action.authorizeCalls, 0);
|
|
});
|
|
|
|
test(
|
|
'a successful authorization hands off instead of continuing',
|
|
() async {
|
|
action.authorizeResult = AuthorizeCode.success;
|
|
|
|
expect(await action.requestAdmin(true), isFalse);
|
|
expect(action.authorizeCalls, 1);
|
|
expect(
|
|
container.read(authorizedTunEnableProvider),
|
|
TunAuthorizationState.authorized,
|
|
);
|
|
},
|
|
);
|
|
|
|
test('a platform without an authorization step continues inline', () async {
|
|
action.authorizeResult = AuthorizeCode.none;
|
|
|
|
expect(await action.requestAdmin(true), isTrue);
|
|
expect(
|
|
container.read(authorizedTunEnableProvider),
|
|
TunAuthorizationState.authorized,
|
|
);
|
|
});
|
|
|
|
test('a failed authorization continues but stays unauthorized', () async {
|
|
action.authorizeResult = AuthorizeCode.error;
|
|
|
|
expect(await action.requestAdmin(true), isTrue);
|
|
expect(
|
|
container.read(authorizedTunEnableProvider),
|
|
TunAuthorizationState.unauthorized,
|
|
);
|
|
});
|
|
});
|
|
|
|
group('updateConfig', () {
|
|
test('safe mode pushes tun disabled even once authorized', () async {
|
|
final core = _MockCoreHandlerInterface();
|
|
when(() => core.updateConfig(any())).thenAnswer((_) async => '');
|
|
final scopedAction = TestSetupAction();
|
|
final scoped = ProviderContainer(
|
|
overrides: [
|
|
profilesProvider.overrideWith(TestProfiles.new),
|
|
setupActionProvider.overrideWith(() => scopedAction),
|
|
coreHandlerProvider.overrideWithValue(CoreController.scoped(core)),
|
|
safeModeProvider.overrideWithValue(true),
|
|
],
|
|
);
|
|
addTearDown(scoped.dispose);
|
|
scoped.read(setupActionProvider.notifier);
|
|
scoped
|
|
.read(patchClashConfigProvider.notifier)
|
|
.update(
|
|
(state) => state.copyWith(tun: state.tun.copyWith(enable: true)),
|
|
);
|
|
scoped.read(authorizedTunEnableProvider.notifier).value =
|
|
TunAuthorizationState.authorized;
|
|
|
|
await scopedAction.updateConfig();
|
|
|
|
final params =
|
|
verify(() => core.updateConfig(captureAny())).captured.single
|
|
as UpdateParams;
|
|
expect(params.tun.enable, isFalse);
|
|
expect(scopedAction.authorizeCalls, 0);
|
|
});
|
|
|
|
test('safe mode closes the external controller', () async {
|
|
final core = _MockCoreHandlerInterface();
|
|
when(() => core.updateConfig(any())).thenAnswer((_) async => '');
|
|
final scopedAction = TestSetupAction();
|
|
final scoped = ProviderContainer(
|
|
overrides: [
|
|
profilesProvider.overrideWith(TestProfiles.new),
|
|
setupActionProvider.overrideWith(() => scopedAction),
|
|
coreHandlerProvider.overrideWithValue(CoreController.scoped(core)),
|
|
safeModeProvider.overrideWithValue(true),
|
|
],
|
|
);
|
|
addTearDown(scoped.dispose);
|
|
scoped.read(setupActionProvider.notifier);
|
|
scoped
|
|
.read(patchClashConfigProvider.notifier)
|
|
.update(
|
|
(state) => state.copyWith(
|
|
externalController: ExternalControllerStatus.open,
|
|
),
|
|
);
|
|
|
|
await scopedAction.updateConfig();
|
|
|
|
final params =
|
|
verify(() => core.updateConfig(captureAny())).captured.single
|
|
as UpdateParams;
|
|
expect(params.externalController, ExternalControllerStatus.close);
|
|
expect(
|
|
scoped.read(patchClashConfigProvider).externalController,
|
|
ExternalControllerStatus.open,
|
|
);
|
|
});
|
|
|
|
Future<UpdateParams> updateWith(PatchClashConfig patchConfig) async {
|
|
final core = _MockCoreHandlerInterface();
|
|
when(() => core.updateConfig(any())).thenAnswer((_) async => '');
|
|
final scopedAction = TestSetupAction();
|
|
final scoped = ProviderContainer(
|
|
overrides: [
|
|
profilesProvider.overrideWith(TestProfiles.new),
|
|
setupActionProvider.overrideWith(() => scopedAction),
|
|
coreHandlerProvider.overrideWithValue(CoreController.scoped(core)),
|
|
],
|
|
);
|
|
addTearDown(scoped.dispose);
|
|
scoped.read(setupActionProvider.notifier);
|
|
scoped.read(patchClashConfigProvider.notifier).value = patchConfig;
|
|
|
|
await scopedAction.updateConfig();
|
|
|
|
return verify(() => core.updateConfig(captureAny())).captured.single
|
|
as UpdateParams;
|
|
}
|
|
|
|
test('hands the Core the controller secret with the address', () async {
|
|
final params = await updateWith(
|
|
const PatchClashConfig(
|
|
externalController: ExternalControllerStatus.open,
|
|
secret: 'abc',
|
|
),
|
|
);
|
|
|
|
expect(params.externalController, ExternalControllerStatus.open);
|
|
expect(params.secret, 'abc');
|
|
});
|
|
|
|
test('never opens the controller without a secret', () async {
|
|
final params = await updateWith(
|
|
const PatchClashConfig(
|
|
externalController: ExternalControllerStatus.open,
|
|
),
|
|
);
|
|
|
|
expect(params.externalController, ExternalControllerStatus.close);
|
|
});
|
|
});
|
|
|
|
group('recoverMissingProfile', () {
|
|
ProviderContainer buildScoped(List<Profile> profiles, int? profileId) {
|
|
final scoped = ProviderContainer(
|
|
overrides: [
|
|
profilesProvider.overrideWith(() => TestProfiles(profiles)),
|
|
currentProfileIdProvider.overrideWithBuild((_, _) => profileId),
|
|
setupActionProvider.overrideWith(TestSetupAction.new),
|
|
],
|
|
);
|
|
addTearDown(scoped.dispose);
|
|
return scoped;
|
|
}
|
|
|
|
test('a dangling profile id falls back to the first profile', () {
|
|
final profile = Profile.normal(label: 'p').copyWith(id: 1);
|
|
final scoped = buildScoped([profile], 404);
|
|
|
|
final recovered = scoped
|
|
.read(setupActionProvider.notifier)
|
|
.recoverMissingProfile();
|
|
|
|
expect(recovered?.id, profile.id);
|
|
expect(scoped.read(currentProfileIdProvider), profile.id);
|
|
});
|
|
|
|
test('no profiles keeps the stored id untouched', () {
|
|
final scoped = buildScoped(const [], 404);
|
|
|
|
expect(
|
|
scoped.read(setupActionProvider.notifier).recoverMissingProfile(),
|
|
isNull,
|
|
);
|
|
expect(scoped.read(currentProfileIdProvider), 404);
|
|
});
|
|
|
|
test('an unset id is the first-run state, not a dangling one', () {
|
|
final profile = Profile.normal(label: 'p').copyWith(id: 1);
|
|
final scoped = buildScoped([profile], null);
|
|
|
|
expect(
|
|
scoped.read(setupActionProvider.notifier).recoverMissingProfile(),
|
|
isNull,
|
|
);
|
|
expect(scoped.read(currentProfileIdProvider), isNull);
|
|
});
|
|
});
|
|
|
|
group('changeMode', () {
|
|
test('records the requested mode', () {
|
|
container.read(setupActionProvider.notifier).changeMode(Mode.direct);
|
|
|
|
expect(container.read(patchClashConfigProvider).mode, Mode.direct);
|
|
});
|
|
|
|
test('leaving global alone keeps the selected group', () {
|
|
final profile = Profile.normal(
|
|
label: 'p',
|
|
).copyWith(id: 1, currentGroupName: 'Manual');
|
|
final scoped = ProviderContainer(
|
|
overrides: [
|
|
profilesProvider.overrideWith(() => TestProfiles([profile])),
|
|
currentProfileIdProvider.overrideWithBuild((_, _) => profile.id),
|
|
setupActionProvider.overrideWith(TestSetupAction.new),
|
|
],
|
|
);
|
|
addTearDown(scoped.dispose);
|
|
|
|
scoped.read(setupActionProvider.notifier).changeMode(Mode.rule);
|
|
|
|
expect(scoped.read(currentProfileProvider)?.currentGroupName, 'Manual');
|
|
});
|
|
|
|
test('global mode also selects the global group', () {
|
|
final profile = Profile.normal(
|
|
label: 'p',
|
|
).copyWith(id: 1, currentGroupName: 'Manual');
|
|
final scoped = ProviderContainer(
|
|
overrides: [
|
|
profilesProvider.overrideWith(() => TestProfiles([profile])),
|
|
currentProfileIdProvider.overrideWithBuild((_, _) => profile.id),
|
|
setupActionProvider.overrideWith(TestSetupAction.new),
|
|
],
|
|
);
|
|
addTearDown(scoped.dispose);
|
|
|
|
scoped.read(setupActionProvider.notifier).changeMode(Mode.global);
|
|
|
|
expect(scoped.read(patchClashConfigProvider).mode, Mode.global);
|
|
expect(
|
|
scoped.read(currentProfileProvider)?.currentGroupName,
|
|
GroupName.GLOBAL.name,
|
|
);
|
|
});
|
|
});
|
|
|
|
group('applyProfileDebounce', () {
|
|
test('collapses a burst into a single apply', () async {
|
|
final notifier = container.read(setupActionProvider.notifier);
|
|
|
|
notifier.applyProfileDebounce(force: true);
|
|
notifier.applyProfileDebounce(force: true);
|
|
notifier.applyProfileDebounce(force: true);
|
|
expect(action.applyProfileCalls, 0);
|
|
|
|
await Future<void>.delayed(const Duration(milliseconds: 800));
|
|
expect(action.applyProfileCalls, 1);
|
|
});
|
|
|
|
test('a stop cancels a pending apply', () async {
|
|
markInitialized();
|
|
final notifier = container.read(setupActionProvider.notifier);
|
|
|
|
notifier.applyProfileDebounce(force: true);
|
|
await notifier.setRunning(false);
|
|
|
|
await Future<void>.delayed(const Duration(milliseconds: 800));
|
|
expect(action.applyProfileCalls, 0);
|
|
});
|
|
});
|
|
|
|
group('_setupConfig via the real SetupAction', () {
|
|
late Directory tempDir;
|
|
late String? originalLastConfigMd5;
|
|
|
|
setUpAll(() async {
|
|
tempDir = Directory.systemTemp.createTempSync('setup_action_test');
|
|
PathProviderPlatform.instance = _FakePathProvider(tempDir.path);
|
|
await AppLocalizations.load(const Locale('en'));
|
|
originalLastConfigMd5 = globalState.lastConfigMd5;
|
|
globalState.packageInfo = PackageInfo(
|
|
appName: 'FlClash',
|
|
packageName: 'com.follow.clash',
|
|
version: '0.0.0',
|
|
buildNumber: '0',
|
|
);
|
|
});
|
|
|
|
tearDownAll(() {
|
|
try {
|
|
tempDir.deleteSync(recursive: true);
|
|
} catch (_) {}
|
|
});
|
|
|
|
tearDown(() {
|
|
globalState.lastConfigMd5 = originalLastConfigMd5;
|
|
});
|
|
|
|
// profileId: null routes getProfile/setupState around the database and
|
|
// Core.getConfig, isolating the behavior under test.
|
|
const nullProfileSetupState = SetupState(
|
|
profileId: null,
|
|
profileLastUpdateDate: null,
|
|
overwriteType: OverwriteType.standard,
|
|
rules: [],
|
|
proxyGroups: [],
|
|
addedRules: [],
|
|
script: null,
|
|
overrideDns: false,
|
|
dns: Dns(),
|
|
dnsOverrideKeys: {},
|
|
);
|
|
|
|
test(
|
|
'a refresh failure still runs core.setupConfig and preloadInvoke',
|
|
() async {
|
|
final profile = Profile.normal(label: 'p', url: 'http://127.0.0.1:9/');
|
|
final core = _MockCoreHandlerInterface();
|
|
when(() => core.setupConfig(any())).thenAnswer((_) async => '');
|
|
var preloadRan = false;
|
|
final scoped = ProviderContainer(
|
|
overrides: [
|
|
profilesProvider.overrideWith(() => TestProfiles([profile])),
|
|
currentProfileIdProvider.overrideWithBuild((_, _) => profile.id),
|
|
setupStateProvider.overrideWith((_, _) => nullProfileSetupState),
|
|
coreHandlerProvider.overrideWithValue(CoreController.scoped(core)),
|
|
setupActionProvider.overrideWith(SetupAction.new),
|
|
],
|
|
);
|
|
addTearDown(scoped.dispose);
|
|
|
|
await scoped
|
|
.read(setupActionProvider.notifier)
|
|
.applyProfile(
|
|
force: true,
|
|
preloadInvoke: () async {
|
|
preloadRan = true;
|
|
},
|
|
);
|
|
|
|
expect(preloadRan, isTrue);
|
|
verify(() => core.setupConfig(any())).called(1);
|
|
},
|
|
);
|
|
|
|
test(
|
|
'a profile that fails to build still pushes the empty config to core',
|
|
() async {
|
|
final profile = Profile.normal(label: 'p');
|
|
final core = _MockCoreHandlerInterface();
|
|
when(() => core.getConfig(any())).thenThrow(Exception('broken yaml'));
|
|
String? pushedConfig;
|
|
when(() => core.setupConfig(any())).thenAnswer((_) async {
|
|
pushedConfig = await File(
|
|
await appPath.configFilePath,
|
|
).readAsString();
|
|
return '';
|
|
});
|
|
final scoped = ProviderContainer(
|
|
overrides: [
|
|
profilesProvider.overrideWith(() => TestProfiles([profile])),
|
|
currentProfileIdProvider.overrideWithBuild((_, _) => profile.id),
|
|
setupStateProvider.overrideWith(
|
|
(_, profileId) =>
|
|
nullProfileSetupState.copyWith(profileId: profileId),
|
|
),
|
|
coreHandlerProvider.overrideWithValue(CoreController.scoped(core)),
|
|
setupActionProvider.overrideWith(SetupAction.new),
|
|
],
|
|
);
|
|
addTearDown(scoped.dispose);
|
|
|
|
final succeeded = await scoped
|
|
.read(setupActionProvider.notifier)
|
|
.applyProfile(force: true);
|
|
|
|
expect(succeeded, isFalse);
|
|
expect(pushedConfig, isEmpty);
|
|
expect(scoped.read(currentProfileIdProvider), profile.id);
|
|
},
|
|
);
|
|
|
|
test(
|
|
'a config write failure reports setup as failed without calling core',
|
|
() async {
|
|
final configPath = await appPath.configFilePath;
|
|
final configFile = File(configPath);
|
|
if (await configFile.exists()) {
|
|
await configFile.delete();
|
|
}
|
|
final configAsDirectory = Directory(configPath);
|
|
await configAsDirectory.create(recursive: true);
|
|
|
|
final core = _MockCoreHandlerInterface();
|
|
when(() => core.setupConfig(any())).thenAnswer((_) async => '');
|
|
final scoped = ProviderContainer(
|
|
overrides: [
|
|
currentProfileProvider.overrideWithValue(null),
|
|
setupStateProvider.overrideWith((_, _) => nullProfileSetupState),
|
|
coreHandlerProvider.overrideWithValue(CoreController.scoped(core)),
|
|
setupActionProvider.overrideWith(SetupAction.new),
|
|
],
|
|
);
|
|
addTearDown(scoped.dispose);
|
|
|
|
try {
|
|
final succeeded = await scoped
|
|
.read(setupActionProvider.notifier)
|
|
.applyProfile(force: true);
|
|
|
|
expect(succeeded, isFalse);
|
|
verifyNever(() => core.setupConfig(any()));
|
|
} finally {
|
|
await configAsDirectory.delete(recursive: true);
|
|
}
|
|
},
|
|
);
|
|
|
|
test(
|
|
'safe mode writes a closed external controller into the profile',
|
|
() async {
|
|
final profile = Profile.normal(label: 'p');
|
|
final core = _MockCoreHandlerInterface();
|
|
when(() => core.getConfig(any())).thenAnswer((_) async => {});
|
|
String? pushedConfig;
|
|
when(() => core.setupConfig(any())).thenAnswer((_) async {
|
|
pushedConfig = await File(
|
|
await appPath.configFilePath,
|
|
).readAsString();
|
|
return '';
|
|
});
|
|
final scoped = ProviderContainer(
|
|
overrides: [
|
|
profilesProvider.overrideWith(() => TestProfiles([profile])),
|
|
currentProfileIdProvider.overrideWithBuild((_, _) => profile.id),
|
|
setupStateProvider.overrideWith(
|
|
(_, profileId) =>
|
|
nullProfileSetupState.copyWith(profileId: profileId),
|
|
),
|
|
coreHandlerProvider.overrideWithValue(CoreController.scoped(core)),
|
|
setupActionProvider.overrideWith(SetupAction.new),
|
|
safeModeProvider.overrideWithValue(true),
|
|
],
|
|
);
|
|
addTearDown(scoped.dispose);
|
|
scoped
|
|
.read(patchClashConfigProvider.notifier)
|
|
.update(
|
|
(state) => state.copyWith(
|
|
externalController: ExternalControllerStatus.open,
|
|
),
|
|
);
|
|
|
|
final succeeded = await scoped
|
|
.read(setupActionProvider.notifier)
|
|
.applyProfile(force: true);
|
|
|
|
expect(succeeded, isTrue);
|
|
expect(pushedConfig, contains('external-controller: ""'));
|
|
expect(pushedConfig, isNot(contains('9090')));
|
|
},
|
|
);
|
|
|
|
test('a custom overwrite injects only the providers it names', () async {
|
|
final profile = Profile.normal(label: 'p');
|
|
final core = _MockCoreHandlerInterface();
|
|
when(() => core.getConfig(any())).thenAnswer(
|
|
(_) async => {
|
|
'proxy-providers': {
|
|
'bundled': {'type': 'http', 'url': 'https://example.com/b.yaml'},
|
|
},
|
|
},
|
|
);
|
|
const appProxy = ClashProvider(
|
|
id: 11,
|
|
kind: ProviderKind.proxy,
|
|
label: 'appProxies',
|
|
url: 'https://example.com/p.yaml',
|
|
);
|
|
const appRule = ClashProvider(
|
|
id: 12,
|
|
kind: ProviderKind.rule,
|
|
label: 'appRules',
|
|
url: 'https://example.com/r.yaml',
|
|
behavior: RuleProviderBehavior.domain,
|
|
format: RuleProviderFormat.mrs,
|
|
);
|
|
const unusedProxy = ClashProvider(
|
|
id: 13,
|
|
kind: ProviderKind.proxy,
|
|
label: 'unused',
|
|
url: 'https://example.com/u.yaml',
|
|
);
|
|
const localRule = ClashProvider(
|
|
id: 14,
|
|
kind: ProviderKind.rule,
|
|
label: 'localRules',
|
|
behavior: RuleProviderBehavior.classical,
|
|
format: RuleProviderFormat.yaml,
|
|
);
|
|
final setupState = nullProfileSetupState.copyWith(
|
|
profileId: profile.id,
|
|
overwriteType: OverwriteType.custom,
|
|
proxyGroups: [
|
|
const ProxyGroup(
|
|
id: 1,
|
|
name: 'g',
|
|
type: GroupType.Selector,
|
|
use: ['appProxies', 'sub'],
|
|
),
|
|
],
|
|
rules: [
|
|
Rule.parse('RULE-SET,appRules,DIRECT', id: 2),
|
|
Rule.parse('RULE-SET,localRules,DIRECT', id: 3),
|
|
],
|
|
clashProviders: const [appProxy, appRule, unusedProxy, localRule],
|
|
profileProviders: const {'sub': 42},
|
|
);
|
|
final scoped = ProviderContainer(
|
|
overrides: [
|
|
coreHandlerProvider.overrideWithValue(CoreController.scoped(core)),
|
|
setupActionProvider.overrideWith(SetupAction.new),
|
|
],
|
|
);
|
|
addTearDown(scoped.dispose);
|
|
|
|
final res = await scoped
|
|
.read(setupActionProvider.notifier)
|
|
.getProfile(
|
|
setupState: setupState,
|
|
patchConfig: const PatchClashConfig(),
|
|
);
|
|
final config = loadYaml(res.yaml) as YamlMap;
|
|
final proxyProviders = config['proxy-providers'] as YamlMap;
|
|
|
|
expect(
|
|
proxyProviders['appProxies']['path'],
|
|
await appPath.getProviderCachePath(
|
|
ProviderKind.proxy,
|
|
appProxy.fileName,
|
|
),
|
|
);
|
|
expect(proxyProviders['sub']['type'], 'file');
|
|
expect(proxyProviders['sub']['path'], await appPath.getProfilePath('42'));
|
|
expect(proxyProviders.containsKey('unused'), isFalse);
|
|
expect(
|
|
proxyProviders['bundled']['path'],
|
|
startsWith(
|
|
await appPath.getProviderDirPath(
|
|
profile.id,
|
|
proxiesProviderDirectoryName,
|
|
),
|
|
),
|
|
);
|
|
expect(config['rule-providers']['appRules']['behavior'], 'domain');
|
|
expect(config['rule-providers']['appRules']['format'], 'mrs');
|
|
expect(
|
|
config['rule-providers']['appRules']['path'],
|
|
await appPath.getProviderCachePath(ProviderKind.rule, appRule.fileName),
|
|
);
|
|
final local = config['rule-providers']['localRules'] as YamlMap;
|
|
expect(local['type'], 'file');
|
|
expect(local.containsKey('url'), isFalse);
|
|
expect(
|
|
local['path'],
|
|
await appPath.getProviderCachePath(
|
|
ProviderKind.rule,
|
|
localRule.fileName,
|
|
),
|
|
);
|
|
});
|
|
|
|
test(
|
|
'a rejected setupConfig without a handoff reports failure, not success',
|
|
() async {
|
|
final core = _MockCoreHandlerInterface();
|
|
when(() => core.setupConfig(any())).thenThrow(StateError('rejected'));
|
|
final scoped = ProviderContainer(
|
|
overrides: [
|
|
currentProfileProvider.overrideWithValue(null),
|
|
setupStateProvider.overrideWith((_, _) => nullProfileSetupState),
|
|
coreHandlerProvider.overrideWithValue(CoreController.scoped(core)),
|
|
setupActionProvider.overrideWith(SetupAction.new),
|
|
],
|
|
);
|
|
addTearDown(scoped.dispose);
|
|
|
|
final succeeded = await scoped
|
|
.read(setupActionProvider.notifier)
|
|
.applyProfile(force: true);
|
|
|
|
expect(succeeded, isFalse);
|
|
verify(() => core.setupConfig(any())).called(1);
|
|
},
|
|
);
|
|
|
|
test(
|
|
'a listener handoff failure during initialize rolls back running',
|
|
() async {
|
|
final core = _MockCoreHandlerInterface();
|
|
when(() => core.setupConfig(any())).thenAnswer((_) async => '');
|
|
final scoped = ProviderContainer(
|
|
overrides: [
|
|
currentProfileProvider.overrideWithValue(null),
|
|
setupStateProvider.overrideWith((_, _) => nullProfileSetupState),
|
|
coreHandlerProvider.overrideWithValue(CoreController.scoped(core)),
|
|
setupActionProvider.overrideWith(
|
|
_ListenerHandoffFailureSetupAction.new,
|
|
),
|
|
],
|
|
);
|
|
addTearDown(scoped.dispose);
|
|
final handoffAction =
|
|
scoped.read(setupActionProvider.notifier)
|
|
as _ListenerHandoffFailureSetupAction;
|
|
|
|
await handoffAction.setRunning(true, initialize: true);
|
|
|
|
expect(handoffAction.coreRunningCalls, [true, false]);
|
|
expect(scoped.read(runTimeProvider), isNull);
|
|
verify(() => core.setupConfig(any())).called(1);
|
|
},
|
|
);
|
|
|
|
test(
|
|
'a non-empty setupConfig message during initialize rolls back running',
|
|
() async {
|
|
final core = _MockCoreHandlerInterface();
|
|
when(
|
|
() => core.setupConfig(any()),
|
|
).thenAnswer((_) async => 'config rejected');
|
|
final scoped = ProviderContainer(
|
|
overrides: [
|
|
currentProfileProvider.overrideWithValue(null),
|
|
setupStateProvider.overrideWith((_, _) => nullProfileSetupState),
|
|
coreHandlerProvider.overrideWithValue(CoreController.scoped(core)),
|
|
setupActionProvider.overrideWith(_MessageFailureSetupAction.new),
|
|
],
|
|
);
|
|
addTearDown(scoped.dispose);
|
|
final messageAction =
|
|
scoped.read(setupActionProvider.notifier)
|
|
as _MessageFailureSetupAction;
|
|
|
|
await messageAction.setRunning(true, initialize: true);
|
|
|
|
expect(messageAction.coreRunningCalls, [true, false]);
|
|
expect(scoped.read(runTimeProvider), isNull);
|
|
verify(() => core.setupConfig(any())).called(1);
|
|
},
|
|
);
|
|
});
|
|
}
|