f64398d3c6
- AppDatabase.open()/openForPush() kapseln Factory, Schluessel (Keystore), Klartext->SQLCipher-Migration (Backup/Verify/atomarer Tausch) und PRAGMAs - Android/Windows/Linux verschluesselt, iOS/macOS bewusst Klartext - 5 neue Tests mit echter SQLCipher-DLL; Windows-Praxistest mit echter DB erfolgreich (23 Tabellen verifiziert, E2EE intakt); Android UNGETESTET
177 lines
6.1 KiB
Dart
177 lines
6.1 KiB
Dart
@TestOn('windows')
|
||
library;
|
||
|
||
import 'dart:ffi';
|
||
import 'dart:io';
|
||
|
||
import 'package:flutter_test/flutter_test.dart';
|
||
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
|
||
import 'package:sqlite3/open.dart';
|
||
import 'package:pyramid/core/app_database.dart';
|
||
|
||
/// Diese Tests fahren die ECHTE Klartext→SQLCipher-Migration – mit derselben
|
||
/// SQLCipher-Bibliothek, die auch die App benutzt: der vom
|
||
/// sqlcipher_flutter_libs-Plugin gebauten `sqlite3.dll` aus dem Windows-Build.
|
||
/// Existiert kein Build-Output (z. B. auf dem Pi), werden sie übersprungen –
|
||
/// dann zählt der Lauf als UNGETESTET im Sinne von CLAUDE.md.
|
||
String? _findCipherDll() {
|
||
for (final config in ['Debug', 'Profile', 'Release']) {
|
||
final file = File('build/windows/x64/runner/$config/sqlite3.dll');
|
||
if (file.existsSync()) return file.absolute.path;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
const _hexKey =
|
||
'a3b1c2d3e4f50617a3b1c2d3e4f50617a3b1c2d3e4f50617a3b1c2d3e4f50617';
|
||
|
||
void main() {
|
||
final dllPath = _findCipherDll();
|
||
final skip = dllPath == null
|
||
? 'SQLCipher-DLL fehlt (erst "flutter build windows" bzw. '
|
||
'"flutter run -d windows" ausführen)'
|
||
: null;
|
||
|
||
// noIsolate: der Override muss im selben Isolate wirken wie die DB-Zugriffe.
|
||
final factory = dllPath == null
|
||
? null
|
||
: createDatabaseFactoryFfi(
|
||
ffiInit: () {
|
||
open.overrideFor(
|
||
OperatingSystem.windows,
|
||
() => DynamicLibrary.open(dllPath),
|
||
);
|
||
},
|
||
noIsolate: true,
|
||
);
|
||
|
||
late Directory tempDir;
|
||
|
||
setUp(() async {
|
||
tempDir = await Directory.systemTemp.createTemp('pyramid_db_test');
|
||
});
|
||
|
||
tearDown(() async {
|
||
try {
|
||
await tempDir.delete(recursive: true);
|
||
} catch (_) {}
|
||
});
|
||
|
||
/// Legt eine Klartext-DB mit zwei Tabellen und bekannten Zeilen an.
|
||
Future<String> createPlaintextDb() async {
|
||
final dbPath = '${tempDir.path}${Platform.pathSeparator}pyramid.sqlite';
|
||
final db = await factory!.openDatabase(dbPath);
|
||
await db.execute('CREATE TABLE box_client (k TEXT PRIMARY KEY, v TEXT)');
|
||
await db.execute(
|
||
'CREATE TABLE box_inbound_group_session (k TEXT PRIMARY KEY, v TEXT)',
|
||
);
|
||
for (var i = 0; i < 50; i++) {
|
||
await db.insert('box_client', {'k': 'key$i', 'v': 'wert$i'});
|
||
}
|
||
for (var i = 0; i < 7; i++) {
|
||
await db.insert(
|
||
'box_inbound_group_session',
|
||
{'k': 'session$i', 'v': 'geheim$i'},
|
||
);
|
||
}
|
||
await db.close();
|
||
return dbPath;
|
||
}
|
||
|
||
test('Geladene Bibliothek ist wirklich SQLCipher (cipher_version)',
|
||
() async {
|
||
final db = await factory!.openDatabase(inMemoryDatabasePath);
|
||
final rows = await db.rawQuery('PRAGMA cipher_version');
|
||
await db.close();
|
||
expect(rows, isNotEmpty,
|
||
reason: 'PRAGMA cipher_version leer → normales sqlite3 geladen');
|
||
expect(rows.first.values.first.toString(), isNotEmpty);
|
||
}, skip: skip);
|
||
|
||
test('Header-Erkennung: Klartext ja, verschlüsselt nein, Mini-Datei ja',
|
||
() async {
|
||
final dbPath = await createPlaintextDb();
|
||
expect(await AppDatabase.isPlaintextSqlite(File(dbPath)), isTrue);
|
||
|
||
final tiny = File('${tempDir.path}${Platform.pathSeparator}tiny.sqlite');
|
||
await tiny.writeAsString('kurz');
|
||
expect(await AppDatabase.isPlaintextSqlite(tiny), isTrue);
|
||
|
||
final encPath = '${tempDir.path}${Platform.pathSeparator}enc.sqlite';
|
||
final enc = await AppDatabase.openEncrypted(factory!, encPath, _hexKey);
|
||
await enc.execute('CREATE TABLE t (x INTEGER)');
|
||
await enc.close();
|
||
expect(await AppDatabase.isPlaintextSqlite(File(encPath)), isFalse);
|
||
}, skip: skip);
|
||
|
||
test('Migration: verschlüsselt, alle Zeilen erhalten, Backup liegt da',
|
||
() async {
|
||
final dbPath = await createPlaintextDb();
|
||
|
||
final ok = await AppDatabase.migratePlaintextToEncrypted(
|
||
factory: factory!,
|
||
dbPath: dbPath,
|
||
hexKey: _hexKey,
|
||
);
|
||
expect(ok, isTrue);
|
||
|
||
// Datei ist jetzt verschlüsselt, Aufräum-Artefakte sind weg.
|
||
expect(await AppDatabase.isPlaintextSqlite(File(dbPath)), isFalse);
|
||
expect(File('$dbPath.enc').existsSync(), isFalse);
|
||
expect(File('$dbPath.migrating').existsSync(), isFalse);
|
||
|
||
// Byte-Backup existiert und ist die alte Klartext-DB.
|
||
final backup = File('$dbPath.premigration');
|
||
expect(backup.existsSync(), isTrue);
|
||
expect(await AppDatabase.isPlaintextSqlite(backup), isTrue);
|
||
|
||
// Inhalt vollständig? Mit Schlüssel öffnen und nachzählen.
|
||
final db = await AppDatabase.openEncrypted(factory, dbPath, _hexKey);
|
||
final clients = await db.rawQuery('SELECT count(*) AS c FROM box_client');
|
||
final sessions = await db
|
||
.rawQuery('SELECT count(*) AS c FROM box_inbound_group_session');
|
||
final probe = await db.rawQuery(
|
||
"SELECT v FROM box_inbound_group_session WHERE k = 'session3'");
|
||
await db.close();
|
||
expect(clients.first['c'], 50);
|
||
expect(sessions.first['c'], 7);
|
||
expect(probe.first['v'], 'geheim3');
|
||
}, skip: skip);
|
||
|
||
test('Falscher Schlüssel scheitert sofort beim Öffnen', () async {
|
||
final dbPath = await createPlaintextDb();
|
||
await AppDatabase.migratePlaintextToEncrypted(
|
||
factory: factory!,
|
||
dbPath: dbPath,
|
||
hexKey: _hexKey,
|
||
);
|
||
final wrongKey = 'ff${_hexKey.substring(2)}';
|
||
await expectLater(
|
||
AppDatabase.openEncrypted(factory, dbPath, wrongKey),
|
||
throwsA(anything),
|
||
);
|
||
}, skip: skip);
|
||
|
||
test('Fehlschlag lässt die Klartext-DB unangetastet weiterlaufen', () async {
|
||
final dbPath = await createPlaintextDb();
|
||
// Fehler erzwingen: an der Stelle des Exports liegt ein VERZEICHNIS –
|
||
// ATTACH kann die Datei nicht anlegen.
|
||
await Directory('$dbPath.enc').create();
|
||
|
||
final ok = await AppDatabase.migratePlaintextToEncrypted(
|
||
factory: factory!,
|
||
dbPath: dbPath,
|
||
hexKey: _hexKey,
|
||
);
|
||
expect(ok, isFalse);
|
||
|
||
// Original unversehrt, Marker weg, App könnte normal weiterlaufen.
|
||
expect(await AppDatabase.isPlaintextSqlite(File(dbPath)), isTrue);
|
||
expect(File('$dbPath.migrating').existsSync(), isFalse);
|
||
final db = await factory.openDatabase(dbPath);
|
||
final clients = await db.rawQuery('SELECT count(*) AS c FROM box_client');
|
||
await db.close();
|
||
expect(clients.first['c'], 50);
|
||
}, skip: skip);
|
||
}
|