Files
pyramid/test/live_logout_race_test.dart
T
Bernd SteckmeisterandClaude Opus 5.5 e90b954348 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]>
2026-10-07 17:39:37 +02:00

183 lines
6.2 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: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)),
);
}
}