mirror of
https://github.com/twonlyapp/twonly-app.git
synced 2026-05-25 09:52:12 +00:00
233 lines
7.4 KiB
Dart
233 lines
7.4 KiB
Dart
import 'dart:io';
|
|
|
|
import 'package:background_downloader/background_downloader.dart';
|
|
import 'package:drift/native.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated.dart';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:twonly/core/bridge.dart' as bridge;
|
|
import 'package:twonly/core/bridge/wrapper/backup.dart';
|
|
import 'package:twonly/core/frb_generated.dart';
|
|
import 'package:twonly/globals.dart';
|
|
import 'package:twonly/locator.dart';
|
|
import 'package:twonly/src/callbacks/callbacks.dart';
|
|
import 'package:twonly/src/database/twonly.db.dart';
|
|
import 'package:twonly/src/model/json/backup.model.dart';
|
|
import 'package:twonly/src/model/json/userdata.model.dart'
|
|
hide LastBackupUploadState;
|
|
import 'package:twonly/src/services/api.service.dart';
|
|
import 'package:twonly/src/services/backup.service.dart';
|
|
import 'package:twonly/src/services/user.service.dart';
|
|
import 'package:twonly/src/utils/keyvalue.dart';
|
|
|
|
void main() {
|
|
if (!Platform.isMacOS) {
|
|
return;
|
|
}
|
|
|
|
TestWidgetsFlutterBinding.ensureInitialized();
|
|
|
|
late Directory tempDir;
|
|
late Map<String, dynamic> initialUserData;
|
|
|
|
setUpAll(() async {
|
|
const channel = MethodChannel('com.bbflight.background_downloader');
|
|
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
|
.setMockMethodCallHandler(channel, (methodCall) async {
|
|
if (methodCall.method == 'enqueue') {
|
|
return true;
|
|
}
|
|
return null;
|
|
});
|
|
|
|
final dylibPath =
|
|
'${Directory.current.path}/rust/target/debug/librust_lib_twonly.dylib';
|
|
if (File(dylibPath).existsSync()) {
|
|
await RustLib.init(
|
|
externalLibrary: ExternalLibrary.open(dylibPath),
|
|
);
|
|
} else {
|
|
await RustLib.init();
|
|
}
|
|
await initFlutterCallbacksForRust();
|
|
|
|
tempDir = Directory.systemTemp.createTempSync('twonly_backup_test_');
|
|
AppEnvironment.initTesting(
|
|
customCacheDir: tempDir.path,
|
|
customSupportDir: tempDir.path,
|
|
);
|
|
|
|
await bridge.initializeTwonlyFlutter(
|
|
config: bridge.InitConfig(
|
|
databaseDir: tempDir.path,
|
|
dataDir: tempDir.path,
|
|
),
|
|
);
|
|
});
|
|
|
|
setUp(() async {
|
|
await locator.reset();
|
|
final dbFile = File('${tempDir.path}/twonly.sqlite');
|
|
locator
|
|
..registerSingleton<TwonlyDB>(
|
|
TwonlyDB(NativeDatabase(dbFile)),
|
|
)
|
|
..registerSingleton<UserService>(UserService())
|
|
..registerSingleton<ApiService>(ApiService());
|
|
|
|
userService.currentUser = UserData(
|
|
userId: 1,
|
|
username: 'test_user',
|
|
displayName: 'Test User',
|
|
subscriptionPlan: 'Free',
|
|
currentSetupPage: null,
|
|
)..appVersion = 100;
|
|
userService.isUserCreated = true;
|
|
await UserService.save(userService.currentUser);
|
|
initialUserData = (await KeyValueStore.get('user'))!;
|
|
|
|
await RustBackupIdentity.setBackupPasswordKeys(
|
|
password: 'strong_password',
|
|
userId: 1,
|
|
);
|
|
});
|
|
|
|
tearDown(() async {
|
|
try {
|
|
await twonlyDB.close();
|
|
} catch (_) {}
|
|
});
|
|
|
|
tearDownAll(() async {
|
|
if (tempDir.existsSync()) {
|
|
try {
|
|
tempDir.deleteSync(recursive: true);
|
|
} catch (_) {}
|
|
}
|
|
});
|
|
|
|
group('BackupService Tests', () {
|
|
test('getData returns default backup status initially', () async {
|
|
final data = await BackupService.getData();
|
|
expect(data.identityState, LastBackupUploadState.none);
|
|
expect(data.archiveState, LastBackupUploadState.none);
|
|
expect(data.identityLastSuccessFull, isNull);
|
|
expect(data.archiveLastSuccessFull, isNull);
|
|
});
|
|
|
|
test(
|
|
'onBackupUpdated stream emits events when backup status changes',
|
|
() async {
|
|
var eventEmitted = false;
|
|
final subscription = BackupService.onBackupUpdated.listen((_) {
|
|
eventEmitted = true;
|
|
});
|
|
|
|
final dummyTask = UploadTask(url: 'http://localhost', filename: 'test');
|
|
await BackupService.handleBackupStatusUpdate(
|
|
'backup_identity',
|
|
TaskStatusUpdate(dummyTask, TaskStatus.complete),
|
|
);
|
|
|
|
await Future.delayed(Duration.zero);
|
|
expect(eventEmitted, isTrue);
|
|
await subscription.cancel();
|
|
},
|
|
);
|
|
|
|
test(
|
|
'handleBackupStatusUpdate updates identity and archive status correctly',
|
|
() async {
|
|
// Test success update for identity status
|
|
final dummyTask1 = UploadTask(
|
|
url: 'http://localhost',
|
|
filename: 'test',
|
|
);
|
|
await BackupService.handleBackupStatusUpdate(
|
|
'backup_identity',
|
|
TaskStatusUpdate(dummyTask1, TaskStatus.complete),
|
|
);
|
|
|
|
var data = await BackupService.getData();
|
|
expect(data.identityState, LastBackupUploadState.success);
|
|
expect(data.identityLastSuccessFull, isNotNull);
|
|
|
|
// Test failure update for archive status
|
|
final dummyTask2 = UploadTask(
|
|
url: 'http://localhost',
|
|
filename: 'test',
|
|
);
|
|
await BackupService.handleBackupStatusUpdate(
|
|
'backup_archive',
|
|
TaskStatusUpdate(dummyTask2, TaskStatus.failed),
|
|
);
|
|
|
|
data = await BackupService.getData();
|
|
expect(data.archiveState, LastBackupUploadState.failed);
|
|
expect(data.archiveLastSuccessFull, isNotNull);
|
|
},
|
|
);
|
|
|
|
test(
|
|
'startFullBackupRecovery returns usernameNotValid for offline/unknown user',
|
|
() async {
|
|
final error = await BackupService.startFullBackupRecovery(
|
|
'unknown_user',
|
|
'password',
|
|
);
|
|
expect(error, RecoveryError.usernameNotValid);
|
|
},
|
|
);
|
|
|
|
test(
|
|
'Full backup recovery flow restores identity and user.json archive successfully',
|
|
() async {
|
|
final initialBackupIdStr = await RustBackupIdentity.getBackupId();
|
|
|
|
// 1. Create backups of baseline state purely natively to avoid background backup races
|
|
final identityBytes = await RustBackupIdentity.getIdentityBackupBytes();
|
|
final (_, archivePath) = await RustBackupArchive.createBackupArchive();
|
|
|
|
// 2. Tamper with user.json data and verify alteration
|
|
await KeyValueStore.put(
|
|
'user',
|
|
{'changed': true, 'username': 'tampered'},
|
|
);
|
|
|
|
final changedUserData = await KeyValueStore.get('user');
|
|
expect(changedUserData?['changed'], isTrue);
|
|
|
|
// 3. Trigger a change of the key_manager before restoring
|
|
await RustBackupIdentity.importBackupPasswordKeys(
|
|
backupId: List.filled(32, 1),
|
|
encryptionKey: List.filled(32, 1),
|
|
);
|
|
|
|
final changedBackupIdStr = await RustBackupIdentity.getBackupId();
|
|
expect(changedBackupIdStr, isNot(equals(initialBackupIdStr)));
|
|
|
|
// 4. Restore identity and archive
|
|
final backupKeys = await RustBackupIdentity.getBackupPasswordKeys(
|
|
userId: 1,
|
|
password: 'strong_password',
|
|
);
|
|
await RustBackupIdentity.restoreIdentityBackup(
|
|
keys: backupKeys,
|
|
encryptedBytes: identityBytes,
|
|
);
|
|
|
|
await RustBackupArchive.restoreBackupArchive(filePath: archivePath);
|
|
|
|
final restoredBackupIdStr = await RustBackupIdentity.getBackupId();
|
|
expect(restoredBackupIdStr, equals(initialBackupIdStr));
|
|
|
|
// 5. Verify user.json data is fully restored
|
|
final restoredUserData = await KeyValueStore.get('user');
|
|
expect(
|
|
restoredUserData?['username'],
|
|
equals(initialUserData['username']),
|
|
);
|
|
},
|
|
);
|
|
});
|
|
}
|