feat(backup): add automatic weekly encrypted backups
A silent safety net against in-app data loss or DB corruption, with zero friction. Driven off the app lifecycle (startup + when hidden), it writes a sealed .tanemaki copy into the app's private storage once a week, keeping the newest 3 (rotating). Reuses the manual backup's encryption via the extracted ExportImportService.buildSealedBackup(); never throws, so a failed copy can't crash startup or backgrounding. Not registered on web (no file storage). Settings shows a quiet reassurance line with the date of the last automatic copy (localised via intl), hidden where no automatic backup exists.
This commit is contained in:
parent
cf6975b748
commit
9c449964fd
19 changed files with 559 additions and 29 deletions
152
apps/app_seeds/test/services/auto_backup_service_test.dart
Normal file
152
apps/app_seeds/test/services/auto_backup_service_test.dart
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:commons_core/commons_core.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:tane/db/database.dart';
|
||||
import 'package:tane/security/secure_key_store.dart';
|
||||
import 'package:tane/services/auto_backup_service.dart';
|
||||
import 'package:tane/services/auto_backup_store.dart';
|
||||
import 'package:tane/services/export_import_service.dart';
|
||||
import 'package:tane/services/file_service.dart';
|
||||
|
||||
import '../support/test_support.dart';
|
||||
|
||||
/// [FileService] the exporter never actually calls for [buildSealedBackup]; the
|
||||
/// automatic path writes files itself.
|
||||
class _UnusedFiles implements FileService {
|
||||
@override
|
||||
Future<String?> saveFile({
|
||||
required String suggestedName,
|
||||
required Uint8List bytes,
|
||||
}) async => null;
|
||||
|
||||
@override
|
||||
Future<Uint8List?> pickFileBytes({List<String>? allowedExtensions}) async =>
|
||||
null;
|
||||
}
|
||||
|
||||
/// An exporter whose backup build always fails, to prove failures stay silent.
|
||||
class _ThrowingExporter extends ExportImportService {
|
||||
_ThrowingExporter(AppDatabase db)
|
||||
: super(
|
||||
repository: newTestRepository(db),
|
||||
files: _UnusedFiles(),
|
||||
keys: SecureKeyStore(store: InMemorySecretStore()),
|
||||
);
|
||||
|
||||
@override
|
||||
Future<Uint8List> buildSealedBackup() => throw Exception('disk full');
|
||||
}
|
||||
|
||||
void main() {
|
||||
late AppDatabase db;
|
||||
late Directory dir;
|
||||
|
||||
setUp(() async {
|
||||
db = newTestDatabase();
|
||||
dir = await Directory.systemTemp.createTemp('tane_auto_backup_test');
|
||||
});
|
||||
tearDown(() async {
|
||||
await db.close();
|
||||
if (dir.existsSync()) await dir.delete(recursive: true);
|
||||
});
|
||||
|
||||
ExportImportService newExporter() => ExportImportService(
|
||||
repository: newTestRepository(db),
|
||||
files: _UnusedFiles(),
|
||||
keys: SecureKeyStore(store: InMemorySecretStore()),
|
||||
);
|
||||
|
||||
AutoBackupService newService(
|
||||
ExportImportService exporter, {
|
||||
required AutoBackupStore store,
|
||||
required DateTime now,
|
||||
}) => AutoBackupService(
|
||||
exporter: exporter,
|
||||
store: store,
|
||||
directory: () async => dir,
|
||||
now: () => now,
|
||||
);
|
||||
|
||||
List<String> autoCopies() =>
|
||||
dir
|
||||
.listSync()
|
||||
.whereType<File>()
|
||||
.map((f) => f.uri.pathSegments.last)
|
||||
.where((n) => n.startsWith('tanemaki-auto-'))
|
||||
.toList()
|
||||
..sort();
|
||||
|
||||
test('first run writes one sealed copy', () async {
|
||||
await newTestRepository(db).addQuickVariety(label: 'Maize');
|
||||
final service = newService(
|
||||
newExporter(),
|
||||
store: AutoBackupStore(InMemorySecretStore()),
|
||||
now: DateTime.utc(2026, 7, 10),
|
||||
);
|
||||
|
||||
expect(await service.runIfDue(), isTrue);
|
||||
|
||||
final copies = autoCopies();
|
||||
expect(copies, ['tanemaki-auto-2026-07-10.tanemaki']);
|
||||
final bytes = File('${dir.path}/${copies.single}').readAsBytesSync();
|
||||
expect(BackupBox.looksSealed(bytes), isTrue);
|
||||
});
|
||||
|
||||
test('a second run within the interval is a no-op', () async {
|
||||
final store = AutoBackupStore(InMemorySecretStore());
|
||||
await store.markBackupAt(DateTime.utc(2026, 7, 8));
|
||||
final service = newService(
|
||||
newExporter(),
|
||||
store: store,
|
||||
now: DateTime.utc(2026, 7, 10), // 2 days later < 7-day interval
|
||||
);
|
||||
|
||||
expect(await service.runIfDue(), isFalse);
|
||||
expect(autoCopies(), isEmpty);
|
||||
});
|
||||
|
||||
test('a run after the interval writes again', () async {
|
||||
final store = AutoBackupStore(InMemorySecretStore());
|
||||
await store.markBackupAt(DateTime.utc(2026, 7, 1));
|
||||
final service = newService(
|
||||
newExporter(),
|
||||
store: store,
|
||||
now: DateTime.utc(2026, 7, 10), // 9 days later >= 7
|
||||
);
|
||||
|
||||
expect(await service.runIfDue(), isTrue);
|
||||
expect(autoCopies(), ['tanemaki-auto-2026-07-10.tanemaki']);
|
||||
});
|
||||
|
||||
test('rotation keeps only the newest three copies', () async {
|
||||
for (final d in ['2020-01-01', '2020-01-02', '2020-01-03', '2020-01-04']) {
|
||||
File('${dir.path}/tanemaki-auto-$d.tanemaki').writeAsBytesSync([0]);
|
||||
}
|
||||
final service = newService(
|
||||
newExporter(),
|
||||
store: AutoBackupStore(InMemorySecretStore()),
|
||||
now: DateTime.utc(2026, 7, 10),
|
||||
);
|
||||
|
||||
await service.runIfDue();
|
||||
|
||||
expect(autoCopies(), [
|
||||
'tanemaki-auto-2020-01-03.tanemaki',
|
||||
'tanemaki-auto-2020-01-04.tanemaki',
|
||||
'tanemaki-auto-2026-07-10.tanemaki',
|
||||
]);
|
||||
});
|
||||
|
||||
test('a failed backup returns false and never throws', () async {
|
||||
final service = newService(
|
||||
_ThrowingExporter(db),
|
||||
store: AutoBackupStore(InMemorySecretStore()),
|
||||
now: DateTime.utc(2026, 7, 10),
|
||||
);
|
||||
|
||||
expect(await service.runIfDue(), isFalse);
|
||||
expect(autoCopies(), isEmpty);
|
||||
});
|
||||
}
|
||||
20
apps/app_seeds/test/services/auto_backup_store_test.dart
Normal file
20
apps/app_seeds/test/services/auto_backup_store_test.dart
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:tane/services/auto_backup_store.dart';
|
||||
|
||||
import '../support/test_support.dart';
|
||||
|
||||
void main() {
|
||||
test('lastBackupAt is null until the first backup is marked', () async {
|
||||
final store = AutoBackupStore(InMemorySecretStore());
|
||||
expect(await store.lastBackupAt(), isNull);
|
||||
});
|
||||
|
||||
test('markBackupAt round-trips to the second', () async {
|
||||
final store = AutoBackupStore(InMemorySecretStore());
|
||||
final when = DateTime.utc(2026, 7, 10, 8, 30);
|
||||
|
||||
await store.markBackupAt(when);
|
||||
|
||||
expect(await store.lastBackupAt(), when);
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue