@TestOn('windows') library; import 'dart:ffi'; import 'dart:io'; import 'package:flutter_test/flutter_test.dart'; import 'package:matrix/matrix.dart'; import 'package:pyramid/core/session_logout.dart'; import 'package:sqflite_common_ffi/sqflite_ffi.dart'; import 'package:sqlite3/open.dart' as sqlite_open; /// LIVE-Test des SDK-Logout-Wettlaufs (ROADMAP M1, gefunden 2026-10-07) mit /// dem Test-Account pyramidtest1 – gegated wie die anderen Live-Tests /// (`PYRAMID_LIVE_TEST=1`), läuft nie im normalen `flutter test`. /// /// Szenario wie in der App: Abmelden und SOFORT auf demselben Client-Objekt /// wieder anmelden, dann 45 s beobachten. Kommt die 401 des alten Long-Polls /// verspätet zurück, löscht das SDK die NEUE Session (`clear()`). /// /// * „Nachweis“ (nur mit zusätzlich `PYRAMID_LIVE_RACE_PROOF=1`): der alte /// Weg (direktes `client.logout()`) – zeigt, ob der Wettlauf auftritt. /// * Regressionstest: `logoutWithoutStraySync` – die neue Session MUSS /// eingeloggt bleiben. Map? _loadAccounts() { final override = Platform.environment['PYRAMID_TEST_ACCOUNTS']; final profile = Platform.environment['USERPROFILE'] ?? ''; final file = File(override ?? '$profile\\.pyramid-autopilot\\test-accounts.txt'); if (!file.existsSync()) return null; final map = {}; for (final line in file.readAsLinesSync()) { final t = line.trim(); if (t.isEmpty || t.startsWith('#')) continue; final i = t.indexOf('='); if (i > 0) map[t.substring(0, i)] = t.substring(i + 1); } return map; } String? _findSqliteDll() { 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; } Future _newClient(String name) async { final dll = _findSqliteDll()!; final factory = createDatabaseFactoryFfi( ffiInit: () { sqlite_open.open.overrideFor( sqlite_open.OperatingSystem.windows, () => DynamicLibrary.open(dll), ); }, noIsolate: true, ); final db = await factory.openDatabase(inMemoryDatabasePath); final client = Client(name, database: await MatrixSdkDatabase.init(name, database: db)); await client.init( waitForFirstSync: false, waitUntilLoadCompletedLoaded: false); return client; } Future _login(Client client, Map acc, String device) async { await client.checkHomeserver(Uri.parse(acc['homeserver']!)); await client.login( LoginType.mLoginPassword, identifier: AuthenticationUserIdentifier(user: acc['user1']!), password: acc['pass1']!, initialDeviceDisplayName: device, refreshToken: true, ); } /// Abmelden → sofort neu anmelden → [watch] lang beobachten. Liefert, ob die /// NEUE Session dabei verloren ging. Future _reloginLosesSession( Map acc, Future Function(Client) logout, String label, { Duration watch = const Duration(seconds: 45), bool logoutRightAfterLogin = false, }) async { final client = await _newClient('PyramidRace$label'); try { await _login(client, acc, 'Autopilot Race $label 1'); if (!logoutRightAfterLogin) { // Warten, bis die Sync-Schleife im Long-Poll hängt (erster Sync hat // timeout 0, danach 30-s-Long-Polls). await client.oneShotSync().timeout(const Duration(seconds: 60)); await Future.delayed(const Duration(seconds: 3)); } // Sonst: sofort abmelden, während die Schleife nach dem ersten (schnellen) // Sync gerade neue Anfragen mit dem alten Token losschickt. await logout(client); expect(client.isLogged(), isFalse); await _login(client, acc, 'Autopilot Race $label 2'); final newToken = client.accessToken; var lost = false; final sub = client.onLoginStateChanged.stream.listen((s) { if (s == LoginState.loggedOut) lost = true; }); final end = DateTime.now().add(watch); while (DateTime.now().isBefore(end) && !lost) { await Future.delayed(const Duration(seconds: 1)); } await sub.cancel(); lost = lost || !client.isLogged() || client.accessToken != newToken; // ignore: avoid_print print('[$label] neue Session nach ${watch.inSeconds}s ' '${lost ? 'VERLOREN' : 'noch eingeloggt'}'); return lost; } finally { if (client.isLogged()) { try { await logoutWithoutStraySync(client); } catch (_) {} } await client.dispose(closeDatabase: true); } } void main() { final acc = _loadAccounts(); final skip = Platform.environment['PYRAMID_LIVE_TEST'] != '1' ? 'Live-Test nur mit PYRAMID_LIVE_TEST=1 (echter Homeserver!)' : acc == null ? 'Test-Account-Datei fehlt' : _findSqliteDll() == null ? 'SQLite-DLL fehlt (erst flutter build windows)' : null; for (final immediately in [false, true]) { final when = immediately ? 'direkt nach dem Login' : 'nach Leerlauf'; final tag = immediately ? 'sofort' : 'leerlauf'; test( 'Nachweis ($when): direktes logout() + sofortiger Re-Login (alter Weg)', () async { final lost = await _reloginLosesSession( acc!, (c) => c.logout(), 'alt-$tag', logoutRightAfterLogin: immediately, ); // Kein expect: dokumentiert nur, ob der Wettlauf auftritt. // ignore: avoid_print print('Wettlauf ($when) mit altem Weg: ${lost ? 'JA' : 'nein'}'); }, skip: skip ?? (Platform.environment['PYRAMID_LIVE_RACE_PROOF'] != '1' ? 'Nachweis nur mit PYRAMID_LIVE_RACE_PROOF=1' : null), timeout: const Timeout(Duration(minutes: 4)), ); test( 'logoutWithoutStraySync ($when): neue Session bleibt nach sofortigem ' 'Re-Login', () async { final lost = await _reloginLosesSession( acc!, logoutWithoutStraySync, 'neu-$tag', logoutRightAfterLogin: immediately, ); expect(lost, isFalse, reason: 'verspätete 401 einer alten Sync-Anfrage hat die neue ' 'Session gelöscht'); }, skip: skip, timeout: const Timeout(Duration(minutes: 4)), ); } }