fix: Abmelden ohne verspaetete Sync-Antwort (SDK-Wettlauf) + Live-Regressionstest
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]>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
f09e4875c6
commit
e90b954348
@@ -0,0 +1,182 @@
|
||||
@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)),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user