5984c161c5
Changelog: Large profiles no longer crash; downloads trust core CAs
851 lines
27 KiB
Dart
851 lines
27 KiB
Dart
import 'dart:convert';
|
||
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';
|
||
|
||
void main() {
|
||
test('encoding helpers round-trip structured data', () async {
|
||
final encoded = jsonEncode({
|
||
'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'));
|
||
});
|
||
|
||
test('a proxy name holding control characters stays readable YAML', () {
|
||
const name = 'HK \x1b[31m\x00\x7f\r
';
|
||
final encoded = yaml.encode({
|
||
'proxies': [
|
||
{'name': name},
|
||
],
|
||
'proxy-groups': [
|
||
{
|
||
'name': 'G',
|
||
'proxies': [name],
|
||
},
|
||
],
|
||
});
|
||
|
||
expect(encoded, isNot(contains('\x1b')));
|
||
final config = loadYaml(encoded) as YamlMap;
|
||
expect(config['proxies'].first['name'], name);
|
||
expect(config['proxy-groups'].first['proxies'], [name]);
|
||
});
|
||
|
||
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']);
|
||
},
|
||
);
|
||
|
||
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>>(
|
||
jsonEncode({
|
||
'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'},
|
||
),
|
||
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',
|
||
]);
|
||
expect(result.counts, (groups: 0, proxies: 0, rules: 3));
|
||
},
|
||
);
|
||
|
||
test('lastMatchTarget reads the last MATCH rule that names a target', () {
|
||
expect(
|
||
lastMatchTarget(['MATCH,A', 'DOMAIN,x.test,B', 'match,C', 'MATCH,']),
|
||
'C',
|
||
);
|
||
expect(lastMatchTarget(['DOMAIN,x.test,B']), isNull);
|
||
});
|
||
|
||
test(
|
||
'makeRealProfileTask routes MATCH placeholders to matchTarget',
|
||
() async {
|
||
final rawConfig = await decodeJSONTask<Map<String, dynamic>>(
|
||
jsonEncode({
|
||
'proxies': [],
|
||
'rules': ['DOMAIN,existing.example,DIRECT', 'MATCH,Original'],
|
||
}),
|
||
);
|
||
final state = MakeRealProfileState(
|
||
profilesPath: '/profiles',
|
||
profileId: 7,
|
||
rawConfig: rawConfig,
|
||
realPatchConfig: const PatchClashConfig(),
|
||
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>>(
|
||
jsonEncode({'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,
|
||
),
|
||
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>>(
|
||
jsonEncode({
|
||
'authentication': ['subscription:injected'],
|
||
'skip-auth-prefixes': ['127.0.0.1/32'],
|
||
}),
|
||
);
|
||
final state = MakeRealProfileState(
|
||
profilesPath: '/profiles',
|
||
profileId: 12,
|
||
rawConfig: rawConfig,
|
||
realPatchConfig: const PatchClashConfig(),
|
||
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(),
|
||
appendSystemDns: false,
|
||
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['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>>(
|
||
jsonEncode({
|
||
'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(),
|
||
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(),
|
||
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(),
|
||
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,
|
||
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>>(
|
||
jsonEncode({'interface-name': 'en0'}),
|
||
);
|
||
|
||
final result = await makeRealProfileTask(
|
||
MakeRealProfileState(
|
||
profilesPath: '/profiles',
|
||
profileId: 13,
|
||
rawConfig: rawConfig,
|
||
realPatchConfig: realPatchConfig,
|
||
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 network settings', () {
|
||
Future<YamlMap> runWith(
|
||
PatchClashConfig realPatchConfig,
|
||
Map<String, dynamic> rawConfig,
|
||
) async {
|
||
final result = await makeRealProfileTask(
|
||
MakeRealProfileState(
|
||
profilesPath: '/profiles',
|
||
profileId: 14,
|
||
rawConfig: await decodeJSONTask<Map<String, dynamic>>(
|
||
jsonEncode(rawConfig),
|
||
),
|
||
realPatchConfig: realPatchConfig,
|
||
appendSystemDns: false,
|
||
proxyGroups: const [],
|
||
rules: const [],
|
||
addedRules: const [],
|
||
defaultUA: 'FlClash-Test',
|
||
),
|
||
);
|
||
return loadYaml(result.yaml) as YamlMap;
|
||
}
|
||
|
||
test('tun settings replace the profile ones and leave the rest', () async {
|
||
final config = await runWith(
|
||
const PatchClashConfig(tun: Tun(mtu: 1400, strictRoute: true)),
|
||
{
|
||
'tun': {'mtu': 9000, 'strict-route': false, 'udp-timeout': 60},
|
||
},
|
||
);
|
||
final tun = config['tun'] as YamlMap;
|
||
|
||
expect(tun['mtu'], 1400);
|
||
expect(tun['strict-route'], true);
|
||
expect(tun['congestion-controller'], 'cubic');
|
||
expect(tun['udp-timeout'], 60);
|
||
});
|
||
|
||
test('outbound settings start at the core defaults', () async {
|
||
final config = await runWith(const PatchClashConfig(), {
|
||
'routing-mark': 1,
|
||
});
|
||
|
||
expect(config['keep-alive-idle'], 15);
|
||
expect(config['routing-mark'], 0);
|
||
});
|
||
|
||
test('set outbound values replace the profile ones', () async {
|
||
final config = await runWith(
|
||
const PatchClashConfig(routingMark: 6666, disableKeepAlive: true),
|
||
{'routing-mark': 1, 'disable-keep-alive': false},
|
||
);
|
||
|
||
expect(config['routing-mark'], 6666);
|
||
expect(config['disable-keep-alive'], true);
|
||
});
|
||
});
|
||
|
||
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(),
|
||
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'));
|
||
});
|
||
}
|