Files
pyramid/test/app_database_test.dart
T
Bernd Steckmeister f64398d3c6 feat: SQLCipher-Verschluesselung der pyramid.sqlite (M1) via neue Storage-Fassade app_database.dart
- 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
2026-07-06 12:31:38 +02:00

177 lines
6.1 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
@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);
}