import 'dart:convert'; import 'dart:io'; import 'package:archive/archive_io.dart'; import 'package:drift/native.dart'; import 'package:fl_clash/common/common.dart'; import 'package:fl_clash/common/migration.dart'; import 'package:fl_clash/database/database.dart' as db; 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 '../helpers/app_paths.dart'; /// The public entry points hand their work to a background isolate, which /// cannot reach the mocked platform channels, so the tests drive the worker /// functions the isolate calls. Those take every path as an argument, so each /// test runs against its own temporary tree. void main() { late Directory root; setUpAll(() async { root = Directory.systemTemp.createTempSync('backup_task_test'); // File.copy does not create parent directories; in production these are the // already-existing temp and cache dirs the platform hands out. Directory(join(root.path, 'tmp')).createSync(recursive: true); Directory(join(root.path, 'archive')).createSync(recursive: true); await useAppPaths(root); }); tearDownAll(() { try { root.deleteSync(recursive: true); } catch (_) {} }); Directory makeDir(String name) => Directory(join(root.path, name, uniqueId))..createSync(recursive: true); File writeFile(String path, String content) => File(path) ..createSync(recursive: true) ..writeAsStringSync(content); group('shakeOrphanFiles', () { test('reports only the files whose name is not a live id', () { final profiles = makeDir('profiles'); final scripts = makeDir('scripts'); final providers = makeDir('providers'); writeFile(join(profiles.path, '1.yaml'), 'keep'); writeFile(join(profiles.path, '2.yaml'), 'drop'); writeFile(join(scripts.path, '10.js'), 'keep'); writeFile(join(scripts.path, '20.js'), 'drop'); writeFile(join(providers.path, '1', 'proxies', 'abc'), 'keep'); writeFile(join(providers.path, '2', 'proxies', 'abc'), 'drop'); final orphans = shakeOrphanFiles( profileIds: [1], scriptIds: [10], profilesDirPath: profiles.path, providersDirPath: providers.path, scriptsDirPath: scripts.path, providerFileNames: const [], providerCacheDirPath: join(root.path, 'missing'), ); expect(orphans.map(basename), unorderedEquals(['2.yaml', '20.js', '2'])); expect( orphans.singleWhere((path) => basename(path) == '2'), join(providers.path, '2'), ); }); test('reports the whole provider directory a dead profile left behind', () { final providers = makeDir('providers'); writeFile(join(providers.path, '3', 'proxies', 'abc'), 'drop'); writeFile(join(providers.path, '3', 'rules', 'def'), 'drop'); writeFile(join(providers.path, 'stray'), 'drop'); final orphans = shakeOrphanFiles( profileIds: const [], scriptIds: const [], profilesDirPath: join(root.path, 'missing'), providersDirPath: providers.path, scriptsDirPath: join(root.path, 'missing'), providerFileNames: const [], providerCacheDirPath: join(root.path, 'missing'), ); expect( orphans, unorderedEquals([ join(providers.path, '3'), join(providers.path, 'stray'), ]), ); }); test('treats an unparsable file name as an orphan', () { final profiles = makeDir('profiles'); writeFile(join(profiles.path, 'stray.yaml'), 'drop'); final orphans = shakeOrphanFiles( profileIds: [1], scriptIds: const [], profilesDirPath: profiles.path, providersDirPath: join(root.path, 'missing'), scriptsDirPath: join(root.path, 'missing'), providerFileNames: const [], providerCacheDirPath: join(root.path, 'missing'), ); expect(orphans.map(basename), ['stray.yaml']); }); test('does not descend into the nested providers folder', () { final profiles = makeDir('profiles'); final nested = Directory(join(profiles.path, 'providers')) ..createSync(recursive: true); writeFile(join(nested.path, '99.yaml'), 'not scanned here'); final orphans = shakeOrphanFiles( profileIds: [1], scriptIds: const [], profilesDirPath: profiles.path, providersDirPath: join(root.path, 'missing'), scriptsDirPath: join(root.path, 'missing'), providerFileNames: const [], providerCacheDirPath: join(root.path, 'missing'), ); expect(orphans, isEmpty); }); test('reports a provider cache file no row names any more', () { final cache = makeDir('provider_cache'); writeFile(join(cache.path, 'rules', 'live'), 'keep'); writeFile(join(cache.path, 'rules', 'stale'), 'drop'); final orphans = shakeOrphanFiles( profileIds: const [], scriptIds: const [], providerFileNames: const ['live'], profilesDirPath: join(root.path, 'missing'), providersDirPath: join(root.path, 'missing'), providerCacheDirPath: cache.path, scriptsDirPath: join(root.path, 'missing'), ); expect(orphans, [join(cache.path, 'rules', 'stale')]); }); test('ignores directories that do not exist', () { expect( shakeOrphanFiles( profileIds: const [], scriptIds: const [], profilesDirPath: join(root.path, 'nope'), providersDirPath: join(root.path, 'nope'), scriptsDirPath: join(root.path, 'nope'), providerFileNames: const [], providerCacheDirPath: join(root.path, 'missing'), ), isEmpty, ); }); }); group('migrateLegacyConfig', () { late Directory source; late Directory target; setUp(() { source = makeDir('legacy_source'); target = makeDir('legacy_target'); }); Map legacyConfig({ List scripts = const [], List rules = const [], List profiles = const [], Map extra = const {}, }) => {'scripts': scripts, 'rules': rules, 'profiles': profiles, ...extra}; test( 'rewrites script ids and writes the content into the target', () async { final data = await migrateLegacyConfig( configMap: legacyConfig( scripts: [ {'id': 'script-a', 'label': 'A', 'content': 'export default 1;'}, ], ), sourcePath: source.path, targetPath: target.path, ); final script = data.scripts.single; expect(script.label, 'A'); expect( File( join(target.path, 'scripts', '${script.id}.js'), ).readAsStringSync(), 'export default 1;', ); }, ); test('skips a script entry that is missing a required field', () async { final data = await migrateLegacyConfig( configMap: legacyConfig( scripts: [ {'id': 'no-content', 'label': 'A'}, {'id': 'ok', 'label': 'B', 'content': 'body'}, ], ), sourcePath: source.path, targetPath: target.path, ); expect(data.scripts.map((item) => item.label), ['B']); }); test('falls back to scriptProps when the scripts list is empty', () async { final data = await migrateLegacyConfig( configMap: legacyConfig( extra: { 'scriptProps': { 'scripts': [ {'id': 'nested', 'label': 'Nested', 'content': 'body'}, ], }, }, ), sourcePath: source.path, targetPath: target.path, ); expect(data.scripts.map((item) => item.label), ['Nested']); }); test('copies the profile file under its new id', () async { writeFile(join(source.path, 'profiles', 'old-1.yaml'), 'proxies: []'); final data = await migrateLegacyConfig( configMap: legacyConfig( profiles: [ {'id': 'old-1', 'label': 'Legacy', 'autoUpdateDuration': 0}, ], ), sourcePath: source.path, targetPath: target.path, ); final profile = data.profiles.single; expect(profile.label, 'Legacy'); expect( File( join(target.path, 'profiles', '${profile.id}.yaml'), ).readAsStringSync(), 'proxies: []', ); }); test('links added rules to the profile that declared them', () async { writeFile(join(source.path, 'profiles', 'p1.yaml'), ''); final data = await migrateLegacyConfig( configMap: legacyConfig( profiles: [ { 'id': 'p1', 'autoUpdateDuration': 0, 'overwrite': { 'type': 'standard', 'standardOverwrite': { 'addedRules': [ {'id': 'r1', 'value': 'DOMAIN,example.com,DIRECT'}, ], }, }, }, ], ), sourcePath: source.path, targetPath: target.path, ); final profile = data.profiles.single; final rule = data.rules.single; expect(rule.content, 'example.com'); expect(rule.profileId, profile.id); expect(profile.type, ProfileType.file); expect(data.disabledRules, isEmpty); }); test( 'keeps a disabled rule only for a rule id it has already seen', () async { writeFile(join(source.path, 'profiles', 'p1.yaml'), ''); final data = await migrateLegacyConfig( configMap: legacyConfig( profiles: [ { 'id': 'p1', 'autoUpdateDuration': 0, 'overwrite': { 'type': 'standard', 'standardOverwrite': { 'addedRules': [ {'id': 'r1', 'value': 'DOMAIN,example.com,DIRECT'}, ], 'disabledRuleIds': ['r1', 'never-seen'], }, }, }, ], ), sourcePath: source.path, targetPath: target.path, ); expect(data.disabledRules, [ DisabledRule( profileId: data.profiles.single.id, ruleId: data.rules.single.id, ), ]); }, ); test('gives a profile with a url the url type', () async { writeFile(join(source.path, 'profiles', 'p1.yaml'), ''); final data = await migrateLegacyConfig( configMap: legacyConfig( profiles: [ { 'id': 'p1', 'url': 'https://example.com/sub', 'autoUpdateDuration': 0, }, ], ), sourcePath: source.path, targetPath: target.path, ); expect(data.profiles.single.type, ProfileType.url); }); test('points the profile at its remapped script id', () async { writeFile(join(source.path, 'profiles', 'p1.yaml'), ''); final data = await migrateLegacyConfig( configMap: legacyConfig( scripts: [ {'id': 's1', 'label': 'S', 'content': 'body'}, ], profiles: [ { 'id': 'p1', 'autoUpdateDuration': 0, 'overwrite': { 'type': 'script', 'scriptOverwrite': {'scriptId': 's1'}, }, }, ], ), sourcePath: source.path, targetPath: target.path, ); expect(data.profiles.single.scriptId, data.scripts.single.id); expect(data.profiles.single.extendType, ExtendType.script); }); test('falls a custom overwrite back to a standard one', () async { writeFile(join(source.path, 'profiles', 'p1.yaml'), ''); final data = await migrateLegacyConfig( configMap: legacyConfig( profiles: [ { 'id': 'p1', 'autoUpdateDuration': 0, 'overwrite': {'type': 'custom'}, }, ], ), sourcePath: source.path, targetPath: target.path, ); expect(data.profiles.single.extendType, ExtendType.standard); }); test('remaps currentProfileId and nulls it when there is none', () async { writeFile(join(source.path, 'profiles', 'p1.yaml'), ''); final configMap = legacyConfig( profiles: [ {'id': 'p1', 'autoUpdateDuration': 0}, ], extra: {'currentProfileId': 'p1'}, ); final data = await migrateLegacyConfig( configMap: configMap, sourcePath: source.path, targetPath: target.path, ); expect(data.configMap?['currentProfileId'], data.profiles.single.id); final without = await migrateLegacyConfig( configMap: legacyConfig(), sourcePath: source.path, targetPath: target.path, ); expect(without.configMap?['currentProfileId'], isNull); }); test('renames the config keys the current schema expects', () async { final data = await migrateLegacyConfig( configMap: legacyConfig( extra: { 'accessControl': {'acceptList': []}, 'isAccessControl': true, 'vpnProps': {}, 'dav': {'uri': 'https://dav.example'}, 'appSetting': {'recoveryStrategy': 'compatible'}, 'proxiesStyle': {'cardType': 'expand'}, }, ), sourcePath: source.path, targetPath: target.path, ); final configMap = data.configMap!; final vpnProps = configMap['vpnProps']! as Map; expect((vpnProps['accessControl'] as Map)['enable'], isTrue); expect(vpnProps['accessControlProps'], vpnProps['accessControl']); expect((configMap['davProps'] as Map)['uri'], 'https://dav.example'); expect( (configMap['appSettingProps'] as Map)['restoreStrategy'], 'compatible', ); expect(configMap['proxiesStyleProps'], configMap['proxiesStyle']); }); test('skips a profile entry without an id', () async { final data = await migrateLegacyConfig( configMap: legacyConfig( profiles: [ {'label': 'no id', 'autoUpdateDuration': 0}, ], ), sourcePath: source.path, targetPath: target.path, ); expect(data.profiles, isEmpty); }); }); group('backup and restore', () { const localProvider = ClashProvider( id: 4, label: 'Local rules', behavior: RuleProviderBehavior.classical, format: RuleProviderFormat.yaml, ); late Directory home; late Directory staging; late String databasePath; setUp(() async { home = makeDir('home'); staging = makeDir('staging'); databasePath = join(home.path, 'database.sqlite'); }); Future seedDatabase() async { final database = db.Database(NativeDatabase(File(databasePath))); await database.profilesDao.putAll([ const Profile( id: 1, label: 'Backed up', autoUpdateDuration: Duration.zero, ).toCompanion(), ]); await database.scriptsDao.setAll([ Script(id: 2, label: 'Script', lastUpdateTime: DateTime(2026)), ]); await database.rulesDao.putRule( const Rule(id: 3, content: 'example.com', order: 'a'), profileId: 1, ); await database.clashProvidersDao.putAll([localProvider.toCompanion()]); await database.close(); } Future backup({ Map configMap = const {'version': 1}, List? entries, }) async { entries ??= BackupEntries.of( profileIds: const [1], scriptIds: const [2], providers: const [localProvider], ); final archivePath = join(root.path, 'archive', '$uniqueId.zip'); await writeBackupArchive( archivePath: archivePath, configMap: configMap, databaseSnapshotPath: databasePath, homeDirPath: home.path, entries: entries, ); return archivePath; } Future zipOf(Map> files) async { final archivePath = join(root.path, 'archive', '$uniqueId.zip'); final encoder = ZipFileEncoder()..create(archivePath); for (final MapEntry(:key, :value) in files.entries) { encoder.addArchiveFile(ArchiveFile.bytes(key, value)); } await encoder.close(); return archivePath; } Future restore(String archivePath) { return readBackupArchive( archivePath: archivePath, stagingDirPath: staging.path, ); } Matcher failsWith(BackupFailure failure) => throwsA( isA().having( (error) => error.failure, 'failure', failure, ), ); test('the archive carries the database, the config and only the listed ' 'files', () async { await seedDatabase(); writeFile(join(home.path, 'profiles', '1.yaml'), 'proxies: []'); writeFile(join(home.path, 'profiles', '9.yaml'), 'not listed'); writeFile(join(home.path, 'scripts', '2.js'), 'body'); final archivePath = await backup(); final names = ZipDecoder() .decodeStream(InputFileStream(archivePath)) .files .map((file) => file.name) .toSet(); expect(names, { backupDatabaseName, configJsonName, 'profiles/1.yaml', 'scripts/2.js', }); }); test('restoring returns the rows and stages the files', () async { await seedDatabase(); writeFile(join(home.path, 'profiles', '1.yaml'), 'proxies: []'); writeFile(join(home.path, 'scripts', '2.js'), 'body'); writeFile( join(home.path, 'providers', 'rules', localProvider.fileName), 'payload: []', ); final archivePath = await backup(); final data = await restore(archivePath); expect(data.profiles.single.label, 'Backed up'); expect(data.scripts.single.label, 'Script'); expect(data.rules.single.content, 'example.com'); expect(data.rules.single.profileId, 1); expect(data.clashProviders.single.label, 'Local rules'); expect(data.configMap?['version'], 1); expect( BackupEntries.ofData(data).map( (entry) => File( BackupEntries.resolve(staging.path, entry), ).readAsStringSync(), ), ['proxies: []', 'body', 'payload: []'], ); }); test('a version 0 archive goes through the legacy migration', () async { await seedDatabase(); writeFile(join(home.path, 'profiles', 'legacy.yaml'), 'proxies: []'); final archivePath = await backup( configMap: { 'version': 0, 'profiles': [ {'id': 'legacy', 'label': 'Old', 'autoUpdateDuration': 0}, ], }, entries: const ['profiles/legacy.yaml'], ); final data = await restore(archivePath); final profile = data.profiles.single; expect(profile.label, 'Old'); expect( profile.id, isNot(0), reason: 'the legacy string id must be replaced by a snowflake id', ); expect( File( BackupEntries.resolve( staging.path, BackupEntries.profile(profile.id), ), ).readAsStringSync(), 'proxies: []', ); }); test( 'an entry that climbs out of the staging directory is dropped', () async { final archivePath = await zipOf({ configJsonName: utf8.encode(json.encode({'version': 1})), '../escaped.txt': utf8.encode('outside'), }); await restore(archivePath); expect( File(join(staging.parent.path, 'escaped.txt')).existsSync(), isFalse, ); }, ); test('an archive without a config document is rejected', () async { final archivePath = await zipOf({'unrelated.txt': utf8.encode('x')}); await expectLater(restore(archivePath), failsWith(BackupFailure.invalid)); }); test('a file that is not a zip archive is rejected', () async { final archivePath = writeFile( join(root.path, 'archive', '$uniqueId.zip'), 'not a zip', ).path; await expectLater(restore(archivePath), failsWith(BackupFailure.invalid)); }); test('a config from a newer data version is rejected', () async { final archivePath = await zipOf({ configJsonName: utf8.encode( json.encode({'version': Migration.currentVersion + 1}), ), }); await expectLater( restore(archivePath), failsWith(BackupFailure.newerVersion), ); }); test('a database from a newer schema is rejected', () async { await seedDatabase(); final raw = db.Database(NativeDatabase(File(databasePath))); await raw.customStatement( 'PRAGMA user_version = ${raw.schemaVersion + 1}', ); await raw.close(); final archivePath = await backup(); await expectLater( restore(archivePath), failsWith(BackupFailure.newerVersion), ); }); test('an archive without a database yields just the config', () async { final archivePath = await zipOf({ configJsonName: utf8.encode(json.encode({'version': 1})), }); final data = await restore(archivePath); expect(data.configMap?['version'], 1); expect(data.profiles, isEmpty); expect(data.scripts, isEmpty); }); }); }