logoutWithoutStraySync (lib/core/session_logout.dart): Sync-Schleife anhalten, laufenden Long-Poll per Account-Daten wecken und auslaufen lassen, erst dann abmelden - es ist keine Anfrage mit altem Token mehr unterwegs, die per 401 ein zweites clear() ausloesen koennte. Genutzt an allen drei Abmelde-Stellen (Einstellungen, Ueber, Konto geloescht) und im E2E-Live-Test. Live gemessen: die verspaetete 401 tritt real auf, kam aber stets vor dem Abschluss einer Neuanmeldung an - Risiko war klein, ist jetzt ausgeschlossen. test/live_logout_race_test.dart in scripts/test.ps1 -Live. Co-Authored-By: Claude Opus 5.5 <[email protected]>
183 lines
6.2 KiB
Dart
183 lines
6.2 KiB
Dart
@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<String, String>? _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 = <String, String>{};
|
||
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<Client> _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<void> _login(Client client, Map<String, String> 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<bool> _reloginLosesSession(
|
||
Map<String, String> acc,
|
||
Future<void> 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)),
|
||
);
|
||
}
|
||
}
|