8081d98c67
Initial public release. Anonymous on-device identity (no phone number, e-mail, or password), Signal-protocol cryptography (X3DH + PQXDH, Double Ratchet, sealed sender) via libsignal, a metadata-minimizing relay, group chats, disappearing messages, encrypted local storage, optional Tor transport, and a UI localized into Czech, English, German, Russian, and Italian. Monorepo: Flutter client (app/), Rust relay + wire types (rust/), protobuf definitions (proto/), self-host deploy (deploy/). Licensed under AGPL-3.0 with an app-store distribution exception (§7).
176 lines
6.9 KiB
Dart
176 lines
6.9 KiB
Dart
// Integrační testy na REÁLNÉM Android runtime (skutečný keychain +
|
|
// SQLCipher, ne mocky). Ověřují běh funkcí, které nejde otestovat v čistém
|
|
// Dart VM. Spouštět na zařízení/emulátoru:
|
|
// flutter test integration_test/features_test.dart -d emulator-5554
|
|
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:integration_test/integration_test.dart';
|
|
import 'package:path_provider/path_provider.dart';
|
|
import 'package:sepot/core/backup/backup_service.dart';
|
|
import 'package:sepot/core/crypto/dek_wrap.dart';
|
|
import 'package:sepot/core/crypto/sepot_crypto.dart';
|
|
import 'package:sepot/core/storage/secure_storage.dart';
|
|
import 'package:sepot/models/conversation.dart';
|
|
import 'package:sepot/models/identity.dart';
|
|
import 'package:sepot/models/message.dart';
|
|
|
|
void main() {
|
|
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
|
|
|
const keystore = FlutterSecureStorage(
|
|
aOptions: AndroidOptions(encryptedSharedPreferences: true),
|
|
);
|
|
|
|
setUpAll(() async {
|
|
await SepotCrypto.init(); // libsignal (RustLib) — jinak LocalIdentity.create padá
|
|
});
|
|
|
|
setUp(() async {
|
|
await keystore.deleteAll();
|
|
});
|
|
|
|
testWidgets('DECOY PIN: skutečný PIN → reálná data, decoy PIN → prázdná DB '
|
|
'(oddělené šifrované databáze)', (tester) async {
|
|
final dir = await getApplicationSupportDirectory();
|
|
final s = SecureStorageService(dbPath: '${dir.path}/it_decoy_${DateTime.now().millisecondsSinceEpoch}.db');
|
|
|
|
// Reálný prostor: odemkni + zapiš marker
|
|
await s.unlockWithoutPin();
|
|
await s.setSetting('marker', 'REAL');
|
|
await s.enablePin('111111');
|
|
await s.enableDecoyPin('222222');
|
|
expect(await s.hasDecoyPin(), isTrue);
|
|
|
|
// Odemčení decoy PINem → prázdná decoy DB
|
|
s.lock();
|
|
await s.unlockWithPin('222222');
|
|
expect(s.isDecoyMode, isTrue, reason: 'decoy PIN má otevřít decoy DB');
|
|
expect(await s.getSetting<String>('marker'), isNull,
|
|
reason: 'decoy DB nesmí vidět reálná data');
|
|
await s.setSetting('marker', 'DECOY');
|
|
|
|
// Odemčení skutečným PINem → reálná data nedotčená
|
|
s.lock();
|
|
await s.unlockWithPin('111111');
|
|
expect(s.isDecoyMode, isFalse);
|
|
expect(await s.getSetting<String>('marker'), 'REAL',
|
|
reason: 'reálná data přežila zápis v decoy módu');
|
|
|
|
// Zpět decoy → jeho zápis přetrval odděleně
|
|
s.lock();
|
|
await s.unlockWithPin('222222');
|
|
expect(await s.getSetting<String>('marker'), 'DECOY');
|
|
|
|
// Špatný PIN (ani real ani decoy) → WrongPinException
|
|
s.lock();
|
|
await expectLater(
|
|
s.unlockWithPin('999999'), throwsA(isA<WrongPinException>()));
|
|
});
|
|
|
|
testWidgets('DECOY PIN se musí lišit od skutečného', (tester) async {
|
|
final dir = await getApplicationSupportDirectory();
|
|
final s = SecureStorageService(dbPath: '${dir.path}/it_conf_${DateTime.now().millisecondsSinceEpoch}.db');
|
|
await s.unlockWithoutPin();
|
|
await s.enablePin('123456');
|
|
await expectLater(
|
|
s.enableDecoyPin('123456'), throwsA(isA<DecoyPinConflict>()));
|
|
});
|
|
|
|
testWidgets('ZMĚNA PINU po nastavení decoy: nový PIN odemkne reálná data, '
|
|
'starý už ne, decoy zůstává funkční', (tester) async {
|
|
final dir = await getApplicationSupportDirectory();
|
|
final s = SecureStorageService(dbPath: '${dir.path}/it_chg_${DateTime.now().millisecondsSinceEpoch}.db');
|
|
|
|
await s.unlockWithoutPin();
|
|
await s.setSetting('marker', 'REAL');
|
|
await s.enablePin('111222');
|
|
await s.enableDecoyPin('999888');
|
|
|
|
// Změna PINu (přesně pořadí z on-device testu: PIN → decoy → změna PINu)
|
|
await s.changePin(oldPin: '111222', newPin: '333444');
|
|
|
|
// Nový PIN odemkne reálná data
|
|
s.lock();
|
|
await s.unlockWithPin('333444');
|
|
expect(s.isDecoyMode, isFalse);
|
|
expect(await s.getSetting<String>('marker'), 'REAL');
|
|
|
|
// Starý PIN už neplatí (a není to ani decoy)
|
|
s.lock();
|
|
await expectLater(
|
|
s.unlockWithPin('111222'), throwsA(isA<WrongPinException>()));
|
|
|
|
// Decoy PIN pořád otevírá prázdnou decoy DB
|
|
s.lock();
|
|
await s.unlockWithPin('999888');
|
|
expect(s.isDecoyMode, isTrue);
|
|
expect(await s.getSetting<String>('marker'), isNull);
|
|
|
|
// Nový PIN se nesmí trefit do decoy
|
|
s.lock();
|
|
await s.unlockWithPin('333444');
|
|
await expectLater(s.changePin(oldPin: '333444', newPin: '999888'),
|
|
throwsA(isA<DecoyPinConflict>()));
|
|
});
|
|
|
|
testWidgets('AUTO-ZÁMEK: lock() za běhu zavře DB, unlock stejným PINem '
|
|
'data vrátí (SQLCipher reopen)', (tester) async {
|
|
final dir = await getApplicationSupportDirectory();
|
|
final s = SecureStorageService(dbPath: '${dir.path}/it_lock_${DateTime.now().millisecondsSinceEpoch}.db');
|
|
|
|
await s.unlockWithoutPin();
|
|
await s.setSetting('marker', 'PRED-ZAMKEM');
|
|
await s.enablePin('111222');
|
|
|
|
// Simulace auto-zámku: zamkni za běhu (DB se zavře)
|
|
s.lock();
|
|
expect(s.isUnlocked, isFalse);
|
|
|
|
// Odemčení TÝMŽ PINem musí projít a vrátit data
|
|
await s.unlockWithPin('111222');
|
|
expect(s.isUnlocked, isTrue);
|
|
expect(await s.getSetting<String>('marker'), 'PRED-ZAMKEM',
|
|
reason: 'auto-zámek nesmí poškodit wrapped DEK ani DB');
|
|
|
|
// Opakovaný cyklus zámek → odemčení (stabilita)
|
|
s.lock();
|
|
await s.unlockWithPin('111222');
|
|
expect(await s.getSetting<String>('marker'), 'PRED-ZAMKEM');
|
|
});
|
|
|
|
testWidgets('ZÁLOHA: export → import obnoví konverzaci (a cizí identita ji '
|
|
'neotevře)', (tester) async {
|
|
final dir = await getApplicationSupportDirectory();
|
|
final ts = DateTime.now().millisecondsSinceEpoch;
|
|
|
|
// Zdroj: konverzace se zprávou
|
|
final src = SecureStorageService(dbPath: '${dir.path}/it_bk_src_$ts.db');
|
|
await src.unlockWithoutPin();
|
|
final conv = Conversation(contactId: 'PEER1', messages: [
|
|
Message.text(senderId: 'PEER1', recipientId: 'me', content: 'ahoj ze zálohy'),
|
|
]);
|
|
await src.saveConversation(conv);
|
|
|
|
final me = await LocalIdentity.create('Já');
|
|
final bytes = await BackupService(src).export(me);
|
|
expect(bytes.length, greaterThan(20));
|
|
|
|
// Cíl: čerstvá DB, import STEJNOU identitou → konverzace zpět
|
|
final dst = SecureStorageService(dbPath: '${dir.path}/it_bk_dst_$ts.db');
|
|
await dst.unlockWithoutPin();
|
|
expect((await dst.loadConversations()).length, 0);
|
|
final n = await BackupService(dst).import(me, bytes);
|
|
expect(n, 1);
|
|
final restored = await dst.loadConversations();
|
|
expect(restored.length, 1);
|
|
expect(restored.first.messages.first.content, 'ahoj ze zálohy');
|
|
|
|
// Cizí identita zálohu neotevře
|
|
final other = await LocalIdentity.create('Nikdo');
|
|
final dst2 = SecureStorageService(dbPath: '${dir.path}/it_bk_x_$ts.db');
|
|
await dst2.unlockWithoutPin();
|
|
await expectLater(
|
|
BackupService(dst2).import(other, bytes), throwsA(isA<FormatException>()));
|
|
});
|
|
}
|