d780cb863d
Changelog: The elevated Core only answers its owner
824 lines
26 KiB
Dart
824 lines
26 KiB
Dart
import 'dart:io';
|
|
|
|
import 'package:fl_clash/common/common.dart';
|
|
import 'package:fl_clash/enum/enum.dart';
|
|
import 'package:fl_clash/models/models.dart';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:path/path.dart';
|
|
import 'package:yaml/yaml.dart';
|
|
|
|
int _double(int value) => value * 2;
|
|
|
|
void main() {
|
|
test('encoding helpers round-trip structured data', () async {
|
|
final encoded = await encodeJSONTask({
|
|
'name': 'FlClash',
|
|
'values': [1, true, null],
|
|
});
|
|
final decoded = await decodeJSONTask<Map<String, dynamic>>(encoded);
|
|
|
|
expect(decoded['name'], 'FlClash');
|
|
expect(decoded['values'], [1, true, null]);
|
|
expect(await encodeYamlTask({'enabled': true}), contains('enabled: true'));
|
|
expect(await encodeMD5Task('abc'), '900150983cd24fb0d6963f7d28e17f72');
|
|
});
|
|
|
|
test('toGroupsTask converts, selects, and sorts core proxy data', () async {
|
|
final proxies = <String, dynamic>{
|
|
'Selector': {
|
|
'name': 'Selector',
|
|
'type': 'Selector',
|
|
'now': 'Beta',
|
|
'all': ['Zulu', 'Beta', 'missing'],
|
|
},
|
|
'Direct': {'name': 'Direct', 'type': 'Direct'},
|
|
'Zulu': {'name': 'Zulu', 'type': 'Direct'},
|
|
'Beta': {'name': 'Beta', 'type': 'Direct'},
|
|
};
|
|
final groups = await toGroupsTask(
|
|
ComputeGroupsState(
|
|
proxiesData: ProxiesData(
|
|
all: const ['Selector', 'Direct'],
|
|
proxies: proxies,
|
|
),
|
|
sortType: ProxiesSortType.name,
|
|
delayMap: const {},
|
|
selectedMap: const {'Selector': 'Beta'},
|
|
defaultTestUrl: 'https://example.com/generate_204',
|
|
),
|
|
);
|
|
|
|
expect(groups, hasLength(1));
|
|
expect(groups.single.name, 'Selector');
|
|
expect(groups.single.all.map((proxy) => proxy.name), ['Beta', 'Zulu']);
|
|
});
|
|
|
|
test(
|
|
'clashConfigTask parses core config data off the main isolate',
|
|
() async {
|
|
final configMap = <String, dynamic>{
|
|
'proxies': [
|
|
{'name': 'Alpha', 'type': 'ss'},
|
|
{'name': 'Beta', 'type': 'vmess'},
|
|
],
|
|
'proxy-groups': [
|
|
{
|
|
'name': 'Auto',
|
|
'type': 'url-test',
|
|
'proxies': ['Alpha', 'Beta'],
|
|
},
|
|
],
|
|
'rules': ['DOMAIN,example.com,Auto'],
|
|
'proxy-providers': {
|
|
'provider': {'type': 'http'},
|
|
},
|
|
'rule-providers': {
|
|
'ruleSet': {'type': 'http'},
|
|
},
|
|
'sub-rules': {'nested': []},
|
|
};
|
|
|
|
final clashConfig = await clashConfigTask(configMap);
|
|
|
|
expect(clashConfig.proxies.map((item) => item.name), ['Alpha', 'Beta']);
|
|
expect(clashConfig.proxyGroups.single.type, GroupType.URLTest);
|
|
expect(clashConfig.rules.single.ruleTarget, 'Auto');
|
|
expect(clashConfig.rules.single.content, 'example.com');
|
|
expect(clashConfig.proxyProviders, ['provider']);
|
|
expect(clashConfig.ruleProviders, ['ruleSet']);
|
|
expect(clashConfig.subRules, ['nested']);
|
|
expect(clashConfig.proxyTypeMap, {
|
|
'Alpha': 'ss',
|
|
'Beta': 'vmess',
|
|
'Auto': 'url-test',
|
|
});
|
|
},
|
|
);
|
|
|
|
test('buildClashConfig indexes group types by their clash value', () {
|
|
final clashConfig = buildClashConfig(<String, dynamic>{
|
|
'proxies': [
|
|
{'name': 'Alpha', 'type': 'ss'},
|
|
],
|
|
'proxy-groups': [
|
|
{
|
|
'name': 'Fallback',
|
|
'type': 'fallback',
|
|
'proxies': ['Alpha'],
|
|
},
|
|
],
|
|
});
|
|
|
|
expect(clashConfig.proxyTypeMap, {'Alpha': 'ss', 'Fallback': 'fallback'});
|
|
expect(clashConfig.rules, isEmpty);
|
|
});
|
|
|
|
test('toGroupsTask leaves the source proxy map untouched', () async {
|
|
final proxies = <String, dynamic>{
|
|
'Selector': {
|
|
'name': 'Selector',
|
|
'type': 'Selector',
|
|
'all': ['Beta'],
|
|
},
|
|
'Beta': {'name': 'Beta', 'type': 'Direct'},
|
|
};
|
|
final state = ComputeGroupsState(
|
|
proxiesData: ProxiesData(all: const ['Selector'], proxies: proxies),
|
|
sortType: ProxiesSortType.none,
|
|
delayMap: const {},
|
|
selectedMap: const {},
|
|
defaultTestUrl: '',
|
|
);
|
|
|
|
await buildGroups(state);
|
|
await buildGroups(state);
|
|
|
|
expect(proxies['Selector']['all'], ['Beta']);
|
|
});
|
|
|
|
test('toGroupsTask returns empty data without proxies', () async {
|
|
final groups = await toGroupsTask(
|
|
const ComputeGroupsState(
|
|
proxiesData: ProxiesData(proxies: {}, all: []),
|
|
sortType: ProxiesSortType.none,
|
|
delayMap: {},
|
|
selectedMap: {},
|
|
defaultTestUrl: '',
|
|
),
|
|
);
|
|
|
|
expect(groups, isEmpty);
|
|
});
|
|
|
|
test(
|
|
'makeRealProfileTask normalizes runtime config and added rules',
|
|
() async {
|
|
final rawConfig = await decodeJSONTask<Map<String, dynamic>>(
|
|
await encodeJSONTask({
|
|
'dns': {
|
|
'enable': true,
|
|
'nameserver': ['1.1.1.1'],
|
|
},
|
|
'sniffer': {
|
|
'sniff': {
|
|
'HTTP': {
|
|
'ports': [80, '443'],
|
|
},
|
|
},
|
|
},
|
|
'proxy-providers': {
|
|
'remote': {'type': 'http', 'url': 'https://example.com/proxy.yaml'},
|
|
'file': {'type': 'file', 'path': './local.yaml'},
|
|
},
|
|
'rule-providers': {
|
|
'remote': {'type': 'http', 'url': 'https://example.com/rule.yaml'},
|
|
},
|
|
'rules': ['DOMAIN,existing.example,DIRECT', 'MATCH,Original'],
|
|
}),
|
|
);
|
|
final result = await makeRealProfileTask(
|
|
MakeRealProfileState(
|
|
profilesPath: '/profiles',
|
|
profileId: 7,
|
|
rawConfig: rawConfig,
|
|
realPatchConfig: const PatchClashConfig(
|
|
mixedPort: 7893,
|
|
port: 7890,
|
|
socksPort: 7891,
|
|
redirPort: 7892,
|
|
tproxyPort: 7894,
|
|
allowLan: true,
|
|
ipv6: true,
|
|
hosts: {'router.local': '192.168.1.1,192.168.1.2'},
|
|
),
|
|
overrideDns: false,
|
|
overrideNtp: false,
|
|
appendSystemDns: true,
|
|
proxyGroups: const [],
|
|
rules: const [],
|
|
addedRules: const [
|
|
Rule(
|
|
ruleAction: RuleAction.DOMAIN_SUFFIX,
|
|
content: 'added.example',
|
|
ruleTarget: 'MATCH',
|
|
),
|
|
],
|
|
defaultUA: 'FlClash-Test',
|
|
),
|
|
);
|
|
final config = loadYaml(result.yaml) as YamlMap;
|
|
|
|
expect(result.md5, hasLength(32));
|
|
expect(config['mixed-port'], 7893);
|
|
expect(config['allow-lan'], true);
|
|
expect(config['global-ua'], 'FlClash-Test');
|
|
expect(config['profile']['store-selected'], false);
|
|
expect(
|
|
config['dns']['nameserver'],
|
|
containsAll(['1.1.1.1', 'system://']),
|
|
);
|
|
expect(config['hosts']['router.local'], ['192.168.1.1', '192.168.1.2']);
|
|
expect(config['sniffer']['sniff']['HTTP']['ports'], ['80', '443']);
|
|
expect(
|
|
config['proxy-providers']['remote']['path'],
|
|
startsWith('/profiles/providers/7/proxies/'),
|
|
);
|
|
expect(
|
|
config['rule-providers']['remote']['path'],
|
|
startsWith('/profiles/providers/7/rules/'),
|
|
);
|
|
expect(config['rules'], [
|
|
'DOMAIN-SUFFIX,added.example,Original',
|
|
'DOMAIN,existing.example,DIRECT',
|
|
'MATCH,Original',
|
|
]);
|
|
},
|
|
);
|
|
|
|
test(
|
|
'makeRealProfileTask routes MATCH placeholders to matchTarget',
|
|
() async {
|
|
final rawConfig = await decodeJSONTask<Map<String, dynamic>>(
|
|
await encodeJSONTask({
|
|
'proxies': [],
|
|
'rules': ['DOMAIN,existing.example,DIRECT', 'MATCH,Original'],
|
|
}),
|
|
);
|
|
final state = MakeRealProfileState(
|
|
profilesPath: '/profiles',
|
|
profileId: 7,
|
|
rawConfig: rawConfig,
|
|
realPatchConfig: const PatchClashConfig(),
|
|
overrideDns: false,
|
|
overrideNtp: false,
|
|
appendSystemDns: false,
|
|
proxyGroups: const [],
|
|
rules: const [],
|
|
addedRules: const [
|
|
Rule(
|
|
ruleAction: RuleAction.DOMAIN_SUFFIX,
|
|
content: 'added.example',
|
|
ruleTarget: 'MATCH',
|
|
),
|
|
],
|
|
defaultUA: 'FlClash-Test',
|
|
matchTarget: 'HK',
|
|
);
|
|
|
|
final overridden = await makeRealProfileTask(state);
|
|
expect((loadYaml(overridden.yaml) as YamlMap)['rules'], [
|
|
'DOMAIN-SUFFIX,added.example,HK',
|
|
'DOMAIN,existing.example,DIRECT',
|
|
'MATCH,Original',
|
|
]);
|
|
|
|
final blank = await makeRealProfileTask(state.copyWith(matchTarget: ' '));
|
|
expect(
|
|
(loadYaml(blank.yaml) as YamlMap)['rules'].first,
|
|
'DOMAIN-SUFFIX,added.example,Original',
|
|
);
|
|
},
|
|
);
|
|
|
|
// The core re-reads these two keys out of the generated config on every
|
|
// profile apply and adopts whatever it finds, so a subscription that ships
|
|
// them would otherwise decide whether GEO databases auto-update — including
|
|
// switching the updater back on after the user turned it off.
|
|
test(
|
|
'makeRealProfileTask lets the app setting own the geo updater',
|
|
() async {
|
|
final rawConfig = await decodeJSONTask<Map<String, dynamic>>(
|
|
await encodeJSONTask({
|
|
'geo-auto-update': true,
|
|
'geo-update-interval': 6,
|
|
}),
|
|
);
|
|
|
|
final result = await makeRealProfileTask(
|
|
MakeRealProfileState(
|
|
profilesPath: '/profiles',
|
|
profileId: 11,
|
|
rawConfig: rawConfig,
|
|
realPatchConfig: const PatchClashConfig(
|
|
geoAutoUpdate: false,
|
|
geoUpdateInterval: 48,
|
|
),
|
|
overrideDns: false,
|
|
overrideNtp: false,
|
|
appendSystemDns: false,
|
|
proxyGroups: const [],
|
|
rules: const [],
|
|
addedRules: const [],
|
|
defaultUA: 'FlClash-Test',
|
|
),
|
|
);
|
|
final config = loadYaml(result.yaml) as YamlMap;
|
|
|
|
expect(config['geo-auto-update'], false);
|
|
expect(config['geo-update-interval'], 48);
|
|
},
|
|
);
|
|
|
|
// A profile-shipped loopback skip-auth-prefixes would bypass the credentials.
|
|
test('makeRealProfileTask lets the app own local authentication', () async {
|
|
final rawConfig = await decodeJSONTask<Map<String, dynamic>>(
|
|
await encodeJSONTask({
|
|
'authentication': ['subscription:injected'],
|
|
'skip-auth-prefixes': ['127.0.0.1/32'],
|
|
}),
|
|
);
|
|
final state = MakeRealProfileState(
|
|
profilesPath: '/profiles',
|
|
profileId: 12,
|
|
rawConfig: rawConfig,
|
|
realPatchConfig: const PatchClashConfig(),
|
|
overrideDns: false,
|
|
overrideNtp: false,
|
|
appendSystemDns: false,
|
|
proxyGroups: const [],
|
|
rules: const [],
|
|
addedRules: const [],
|
|
defaultUA: 'FlClash-Test',
|
|
authentication: const ['user:pass'],
|
|
);
|
|
|
|
final enabled = await makeRealProfileTask(state);
|
|
final enabledConfig = loadYaml(enabled.yaml) as YamlMap;
|
|
expect(enabledConfig['authentication'], ['user:pass']);
|
|
expect(enabledConfig['skip-auth-prefixes'], isEmpty);
|
|
|
|
final disabled = await makeRealProfileTask(
|
|
state.copyWith(authentication: const []),
|
|
);
|
|
final disabledConfig = loadYaml(disabled.yaml) as YamlMap;
|
|
expect(disabledConfig['authentication'], isEmpty);
|
|
expect(disabledConfig['skip-auth-prefixes'], isEmpty);
|
|
});
|
|
|
|
test('makeRealProfileTask overrides DNS and explicit custom data', () async {
|
|
final result = await makeRealProfileTask(
|
|
const MakeRealProfileState(
|
|
profilesPath: '/profiles',
|
|
profileId: 9,
|
|
rawConfig: {},
|
|
realPatchConfig: PatchClashConfig(),
|
|
overrideDns: true,
|
|
overrideNtp: false,
|
|
appendSystemDns: false,
|
|
proxies: [
|
|
CustomProxy(
|
|
id: 2,
|
|
definition: {'name': 'Node', 'type': 'socks5', 'port': 1080},
|
|
),
|
|
],
|
|
proxyGroups: [
|
|
ProxyGroup(
|
|
id: 1,
|
|
name: 'Select',
|
|
type: GroupType.Selector,
|
|
proxies: ['DIRECT'],
|
|
),
|
|
],
|
|
rules: [
|
|
Rule(
|
|
ruleAction: RuleAction.DOMAIN,
|
|
content: 'custom.example',
|
|
ruleTarget: 'DIRECT',
|
|
),
|
|
],
|
|
addedRules: [],
|
|
defaultUA: 'Fallback-UA',
|
|
),
|
|
);
|
|
final config = loadYaml(result.yaml) as YamlMap;
|
|
|
|
expect(config['dns']['enable'], true);
|
|
expect(config['dns']['nameserver'], isNot(contains('system://')));
|
|
expect(config['proxies'], [
|
|
{'name': 'Node', 'type': 'socks5', 'port': 1080},
|
|
]);
|
|
expect(config['proxy-groups'], hasLength(1));
|
|
expect(config['proxy-groups'][0].keys, ['name', 'type', 'proxies']);
|
|
expect(config['proxy-groups'][0]['type'], 'select');
|
|
expect(config['rules'], ['DOMAIN,custom.example,DIRECT']);
|
|
});
|
|
|
|
test('makeRealProfileTask keeps the DNS keys it cannot edit', () async {
|
|
final rawConfig = await decodeJSONTask<Map<String, dynamic>>(
|
|
await encodeJSONTask({
|
|
'dns': {
|
|
'enable': false,
|
|
'direct-nameserver': ['223.5.5.5'],
|
|
'proxy-server-nameserver-policy': {
|
|
'www.example.com': ['8.8.8.8'],
|
|
},
|
|
'nameserver': ['9.9.9.9'],
|
|
},
|
|
'proxy-providers': {
|
|
'first': {'type': 'http', 'url': 'https://example.com/shared.yaml'},
|
|
'second': {'type': 'http', 'url': 'https://example.com/shared.yaml'},
|
|
},
|
|
}),
|
|
);
|
|
|
|
final result = await makeRealProfileTask(
|
|
MakeRealProfileState(
|
|
profilesPath: '/profiles',
|
|
profileId: 13,
|
|
rawConfig: rawConfig,
|
|
realPatchConfig: const PatchClashConfig(),
|
|
overrideDns: false,
|
|
overrideNtp: false,
|
|
appendSystemDns: false,
|
|
proxyGroups: const [],
|
|
rules: const [],
|
|
addedRules: const [],
|
|
defaultUA: 'FlClash-Test',
|
|
),
|
|
);
|
|
final config = loadYaml(result.yaml) as YamlMap;
|
|
|
|
expect(config['dns']['direct-nameserver'], ['223.5.5.5']);
|
|
expect(config['dns']['proxy-server-nameserver-policy'], {
|
|
'www.example.com': ['8.8.8.8'],
|
|
});
|
|
expect(config['dns']['nameserver'], isNot(contains('system://')));
|
|
expect(
|
|
config['proxy-providers']['first']['path'],
|
|
isNot(config['proxy-providers']['second']['path']),
|
|
);
|
|
});
|
|
|
|
test('makeRealProfileTask keeps safe mode off the host', () async {
|
|
Future<YamlMap> build({required bool safeMode}) async {
|
|
final result = await makeRealProfileTask(
|
|
MakeRealProfileState(
|
|
profilesPath: '/profiles',
|
|
profileId: 14,
|
|
rawConfig: {
|
|
'dns': {'enable': true, 'listen': '0.0.0.0:53'},
|
|
'ntp': {'enable': true, 'write-to-system': true},
|
|
},
|
|
realPatchConfig: const PatchClashConfig(),
|
|
overrideDns: false,
|
|
overrideNtp: false,
|
|
appendSystemDns: false,
|
|
proxyGroups: const [],
|
|
rules: const [],
|
|
addedRules: const [],
|
|
defaultUA: 'FlClash-Test',
|
|
safeMode: safeMode,
|
|
),
|
|
);
|
|
return loadYaml(result.yaml) as YamlMap;
|
|
}
|
|
|
|
final normal = await build(safeMode: false);
|
|
expect(normal['dns']['listen'], '0.0.0.0:53');
|
|
expect(normal['ntp']['write-to-system'], true);
|
|
|
|
final safe = await build(safeMode: true);
|
|
expect(safe['dns']['listen'], '');
|
|
expect(safe['ntp']['write-to-system'], false);
|
|
expect(safe['ntp']['enable'], true);
|
|
});
|
|
|
|
test(
|
|
'makeRealProfileTask does not let a profile open a controller',
|
|
() async {
|
|
for (final safeMode in [false, true]) {
|
|
final result = await makeRealProfileTask(
|
|
MakeRealProfileState(
|
|
profilesPath: '/profiles',
|
|
profileId: 14,
|
|
rawConfig: {
|
|
'external-controller': '0.0.0.0:9090',
|
|
'secret': 'from-the-profile',
|
|
'external-controller-tls': '0.0.0.0:9443',
|
|
'external-controller-unix': '/tmp/victim',
|
|
'external-controller-pipe': r'\\.\pipe\mihomo',
|
|
},
|
|
realPatchConfig: const PatchClashConfig(),
|
|
overrideDns: false,
|
|
overrideNtp: false,
|
|
appendSystemDns: false,
|
|
proxyGroups: const [],
|
|
rules: const [],
|
|
addedRules: const [],
|
|
defaultUA: 'FlClash-Test',
|
|
safeMode: safeMode,
|
|
),
|
|
);
|
|
final config = loadYaml(result.yaml) as YamlMap;
|
|
|
|
expect(config['external-controller-tls'], '');
|
|
expect(config['external-controller-unix'], '');
|
|
expect(config['external-controller-pipe'], '');
|
|
expect(config['external-controller'], '');
|
|
expect(config['secret'], '');
|
|
}
|
|
},
|
|
);
|
|
|
|
test(
|
|
'makeRealProfileTask writes the app secret over the profile one',
|
|
() async {
|
|
Future<YamlMap> build(PatchClashConfig realPatchConfig) async {
|
|
final result = await makeRealProfileTask(
|
|
MakeRealProfileState(
|
|
profilesPath: '/profiles',
|
|
profileId: 14,
|
|
rawConfig: {'secret': 'from-the-profile'},
|
|
realPatchConfig: realPatchConfig,
|
|
overrideDns: false,
|
|
overrideNtp: false,
|
|
appendSystemDns: false,
|
|
proxyGroups: const [],
|
|
rules: const [],
|
|
addedRules: const [],
|
|
defaultUA: 'FlClash-Test',
|
|
),
|
|
);
|
|
return loadYaml(result.yaml) as YamlMap;
|
|
}
|
|
|
|
final open = await build(
|
|
const PatchClashConfig(
|
|
externalController: ExternalControllerStatus.open,
|
|
secret: 'from-the-app',
|
|
),
|
|
);
|
|
expect(open['external-controller'], '127.0.0.1:9090');
|
|
expect(open['secret'], 'from-the-app');
|
|
|
|
final closed = await build(const PatchClashConfig());
|
|
expect(closed['secret'], '');
|
|
final missingSecret = await build(
|
|
const PatchClashConfig(
|
|
externalController: ExternalControllerStatus.open,
|
|
),
|
|
);
|
|
expect(missingSecret['external-controller'], '');
|
|
},
|
|
);
|
|
|
|
test(
|
|
'profile listeners and host changes are disabled only in safe mode',
|
|
() async {
|
|
for (final safeMode in [false, true]) {
|
|
final result = await makeRealProfileTask(
|
|
MakeRealProfileState(
|
|
profilesPath: '/profiles',
|
|
profileId: 14,
|
|
rawConfig: {
|
|
'listeners': [
|
|
{'type': 'mixed', 'name': 'extra', 'port': 1234},
|
|
{'type': 'tun', 'name': 'extra-tun', 'auto-route': true},
|
|
],
|
|
'tunnels': [
|
|
{
|
|
'network': ['tcp'],
|
|
'address': '0.0.0.0:1234',
|
|
'target': 'example.test:443',
|
|
},
|
|
],
|
|
'ss-config': 'ss://cipher:password@:1234',
|
|
'vmess-config': 'vmess://uuid@:1234',
|
|
'tuic-server': {'enable': true, 'listen': '0.0.0.0:1234'},
|
|
'iptables': {'enable': true, 'dns-redirect': true},
|
|
'dns': {'enable': true, 'listen': '0.0.0.0:53'},
|
|
'ntp': {'enable': true, 'write-to-system': true},
|
|
},
|
|
realPatchConfig: const PatchClashConfig(
|
|
externalController: ExternalControllerStatus.open,
|
|
secret: 'from-the-app',
|
|
),
|
|
appendSystemDns: false,
|
|
proxyGroups: const [],
|
|
rules: const [],
|
|
addedRules: const [],
|
|
defaultUA: 'FlClash-Test',
|
|
safeMode: safeMode,
|
|
),
|
|
);
|
|
final config = loadYaml(result.yaml) as YamlMap;
|
|
if (safeMode) {
|
|
expect(config['listeners'], isEmpty);
|
|
expect(config['tunnels'], isEmpty);
|
|
expect(config['ss-config'], '');
|
|
expect(config['vmess-config'], '');
|
|
expect(config['tuic-server']['enable'], false);
|
|
expect(config['iptables']['enable'], false);
|
|
expect(config['dns']['listen'], '');
|
|
expect(config['ntp']['write-to-system'], false);
|
|
} else {
|
|
expect(config['listeners'], [
|
|
{'type': 'mixed', 'name': 'extra', 'port': 1234},
|
|
{'type': 'tun', 'name': 'extra-tun', 'auto-route': true},
|
|
]);
|
|
expect(config['tunnels'], [
|
|
{
|
|
'network': ['tcp'],
|
|
'address': '0.0.0.0:1234',
|
|
'target': 'example.test:443',
|
|
},
|
|
]);
|
|
expect(config['ss-config'], 'ss://cipher:password@:1234');
|
|
expect(config['vmess-config'], 'vmess://uuid@:1234');
|
|
expect(config['tuic-server'], {
|
|
'enable': true,
|
|
'listen': '0.0.0.0:1234',
|
|
});
|
|
expect(config['iptables'], {'enable': true, 'dns-redirect': true});
|
|
expect(config['dns']['listen'], '0.0.0.0:53');
|
|
expect(config['ntp']['write-to-system'], true);
|
|
}
|
|
expect(config['external-controller'], safeMode ? '' : '127.0.0.1:9090');
|
|
}
|
|
},
|
|
);
|
|
|
|
test('iptables interface names cannot add command arguments', () async {
|
|
for (final interfaceName in ['br-lan.10', 'lo -M /tmp/extra']) {
|
|
final result = await makeRealProfileTask(
|
|
MakeRealProfileState(
|
|
profilesPath: '/profiles',
|
|
profileId: 14,
|
|
rawConfig: {
|
|
'iptables': {
|
|
'enable': true,
|
|
'inbound-interface': interfaceName,
|
|
'bypass': ['192.168.0.0/16'],
|
|
},
|
|
},
|
|
realPatchConfig: const PatchClashConfig(),
|
|
appendSystemDns: false,
|
|
proxyGroups: const [],
|
|
rules: const [],
|
|
addedRules: const [],
|
|
defaultUA: 'FlClash-Test',
|
|
),
|
|
);
|
|
final config = loadYaml(result.yaml) as YamlMap;
|
|
expect(
|
|
config['iptables'],
|
|
interfaceName == 'br-lan.10'
|
|
? {
|
|
'enable': true,
|
|
'inbound-interface': interfaceName,
|
|
'bypass': ['192.168.0.0/16'],
|
|
}
|
|
: {'enable': false},
|
|
);
|
|
}
|
|
});
|
|
|
|
group('makeRealProfileTask interface-name mode', () {
|
|
Future<YamlMap> runWith(PatchClashConfig realPatchConfig) async {
|
|
final rawConfig = await decodeJSONTask<Map<String, dynamic>>(
|
|
await encodeJSONTask({'interface-name': 'en0'}),
|
|
);
|
|
|
|
final result = await makeRealProfileTask(
|
|
MakeRealProfileState(
|
|
profilesPath: '/profiles',
|
|
profileId: 13,
|
|
rawConfig: rawConfig,
|
|
realPatchConfig: realPatchConfig,
|
|
overrideDns: false,
|
|
overrideNtp: false,
|
|
appendSystemDns: false,
|
|
proxyGroups: const [],
|
|
rules: const [],
|
|
addedRules: const [],
|
|
defaultUA: 'FlClash-Test',
|
|
),
|
|
);
|
|
return loadYaml(result.yaml) as YamlMap;
|
|
}
|
|
|
|
// Default mode, so a subscription value must not survive into the
|
|
// generated config.
|
|
test('clear forces interface-name empty', () async {
|
|
final config = await runWith(const PatchClashConfig());
|
|
|
|
expect(config['interface-name'], '');
|
|
});
|
|
|
|
test('follow leaves the profile value untouched', () async {
|
|
final config = await runWith(
|
|
const PatchClashConfig(interfaceNameMode: InterfaceNameMode.follow),
|
|
);
|
|
|
|
expect(config['interface-name'], 'en0');
|
|
});
|
|
|
|
test('custom writes the configured interface name', () async {
|
|
final config = await runWith(
|
|
const PatchClashConfig(
|
|
interfaceNameMode: InterfaceNameMode.custom,
|
|
interfaceName: 'eth0',
|
|
),
|
|
);
|
|
|
|
expect(config['interface-name'], 'eth0');
|
|
});
|
|
});
|
|
|
|
group('makeRealProfileTask legacy provider file migration', () {
|
|
late Directory tempDir;
|
|
const url = 'https://example.com/proxy.yaml';
|
|
const name = 'remote';
|
|
|
|
setUp(() {
|
|
tempDir = Directory.systemTemp.createTempSync('task_test_providers');
|
|
});
|
|
|
|
tearDown(() {
|
|
if (tempDir.existsSync()) {
|
|
tempDir.deleteSync(recursive: true);
|
|
}
|
|
});
|
|
|
|
Future<({String legacyPath, String newPath})> runMigration() async {
|
|
final providerDir = join(
|
|
tempDir.path,
|
|
providersDirectoryName,
|
|
'17',
|
|
proxiesProviderDirectoryName,
|
|
);
|
|
final legacyPath = join(providerDir, url.toMd5());
|
|
final newPath = join(providerDir, '$name@$url'.toMd5());
|
|
|
|
final result = await makeRealProfileTask(
|
|
MakeRealProfileState(
|
|
profilesPath: tempDir.path,
|
|
profileId: 17,
|
|
rawConfig: {
|
|
'proxy-providers': {
|
|
name: {'type': 'http', 'url': url},
|
|
},
|
|
},
|
|
realPatchConfig: const PatchClashConfig(),
|
|
overrideDns: false,
|
|
overrideNtp: false,
|
|
appendSystemDns: false,
|
|
proxyGroups: const [],
|
|
rules: const [],
|
|
addedRules: const [],
|
|
defaultUA: 'FlClash-Test',
|
|
),
|
|
);
|
|
final config = loadYaml(result.yaml) as YamlMap;
|
|
expect(config['proxy-providers'][name]['path'], newPath);
|
|
return (legacyPath: legacyPath, newPath: newPath);
|
|
}
|
|
|
|
test('renames a file cached under the legacy url-only key', () async {
|
|
final providerDir = join(
|
|
tempDir.path,
|
|
providersDirectoryName,
|
|
'17',
|
|
proxiesProviderDirectoryName,
|
|
);
|
|
await Directory(providerDir).create(recursive: true);
|
|
final legacyFile = File(join(providerDir, url.toMd5()));
|
|
await legacyFile.writeAsString('cached-provider-data');
|
|
|
|
final paths = await runMigration();
|
|
|
|
expect(File(paths.newPath).existsSync(), isTrue);
|
|
expect(await File(paths.newPath).readAsString(), 'cached-provider-data');
|
|
expect(File(paths.legacyPath).existsSync(), isFalse);
|
|
});
|
|
|
|
test('is a no-op when no legacy file exists', () async {
|
|
final paths = await runMigration();
|
|
|
|
expect(File(paths.legacyPath).existsSync(), isFalse);
|
|
expect(File(paths.newPath).existsSync(), isFalse);
|
|
});
|
|
});
|
|
|
|
test('log and list tasks produce stable mapped output', () async {
|
|
final logs = [
|
|
const Log(
|
|
logLevel: LogLevel.info,
|
|
payload: 'first',
|
|
dateTime: '2026-07-26 10:00:00',
|
|
),
|
|
const Log(
|
|
logLevel: LogLevel.error,
|
|
payload: 'second',
|
|
dateTime: '2026-07-26 10:00:01',
|
|
),
|
|
];
|
|
|
|
final encoded = await encodeLogsTask(logs);
|
|
|
|
expect(encoded, contains('first'));
|
|
expect(encoded, contains('\n'));
|
|
expect(await mapListTask([1, 2, 3], _double), [2, 4, 6]);
|
|
});
|
|
}
|