test: Regressionstests für Soft-Logout-Guard und Power-Levels-Schutz

Erste Testsuite des Projekts, gezielt für die zwei heiligsten Codepfade:
guardedSoftLogoutRefresh (wirft nie -> kein SDK-logout()+clear(), Uta-Bug)
und updatePowerLevelsSafely (Selbst-Aussperr-Schutz, Server-Stand bleibt
erhalten). 10 Tests, alle gruen auf dem Pi (flutter test laeuft headless).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Bernd Steckmeister
2026-07-03 22:21:48 +02:00
parent bfe58760ee
commit 99cde9a1e2
4 changed files with 285 additions and 0 deletions
+170
View File
@@ -0,0 +1,170 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:matrix/matrix.dart';
import 'package:pyramid/features/spaces/space_admin_dialog.dart';
/// Fake-Client für `updatePowerLevelsSafely`: liefert einen vorgegebenen
/// Server-Stand des power_levels-Events und zeichnet auf, was (wenn
/// überhaupt) zurückgeschrieben wird. Alles andere → NoSuchMethodError.
class _FakeClient implements Client {
_FakeClient({required this.serverState, this.getError});
Map<String, Object?> serverState;
MatrixException? getError;
Map<String, Object?>? written;
@override
String? get userID => '@me:pyramid.example';
@override
Future<Map<String, Object?>> getRoomStateWithKey(
String roomId,
String eventType,
String stateKey, {
Format? format,
}) async {
final err = getError;
if (err != null) throw err;
return serverState;
}
@override
Future<String> setRoomStateWithKey(
String roomId,
String eventType,
String stateKey,
Map<String, Object?> body,
) async {
written = body;
return r'$neuesEvent';
}
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
class _FakeRoom implements Room {
_FakeRoom(this.client);
@override
final Client client;
@override
String get id => '!raum:pyramid.example';
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
void main() {
test('Server-Stand bleibt erhalten: fremde Einträge werden nie überschrieben '
'(genau der Bug, der schon einmal Admins ausgesperrt hat)', () async {
final client = _FakeClient(serverState: {
'users': {'@me:pyramid.example': 100, '@uta:pyramid.example': 50},
'users_default': 0,
'state_default': 50,
});
final room = _FakeRoom(client);
await updatePowerLevelsSafely(room, (content) {
(content['users'] as Map<String, dynamic>)['@neu:pyramid.example'] = 50;
});
final users = client.written!['users'] as Map;
expect(users['@uta:pyramid.example'], 50,
reason: 'bestehender Server-Eintrag muss überleben');
expect(users['@me:pyramid.example'], 100);
expect(users['@neu:pyramid.example'], 50);
});
test('Selbst-Degradierung unter das nötige Level wird abgebrochen, '
'nichts wird geschrieben', () async {
final client = _FakeClient(serverState: {
'users': {'@me:pyramid.example': 100},
'state_default': 50,
});
final room = _FakeRoom(client);
await expectLater(
updatePowerLevelsSafely(room, (content) {
(content['users'] as Map<String, dynamic>)['@me:pyramid.example'] = 0;
}),
throwsException,
);
expect(client.written, isNull);
});
test('eigener Eintrag entfernt → Fallback auf users_default greift und '
'schützt ebenfalls', () async {
final client = _FakeClient(serverState: {
'users': {'@me:pyramid.example': 100},
'users_default': 0,
});
final room = _FakeRoom(client);
await expectLater(
updatePowerLevelsSafely(room, (content) {
(content['users'] as Map<String, dynamic>)
.remove('@me:pyramid.example');
}),
throwsException,
);
expect(client.written, isNull);
});
test('explizites events[m.room.power_levels]-Erfordernis wird respektiert',
() async {
final client = _FakeClient(serverState: {
'users': {'@me:pyramid.example': 60},
'events': {'m.room.power_levels': 75},
'state_default': 50,
});
final room = _FakeRoom(client);
// 60 < 75 → muss abbrechen, obwohl state_default (50) erfüllt wäre.
await expectLater(
updatePowerLevelsSafely(room, (_) {}),
throwsException,
);
expect(client.written, isNull);
});
test('fehlendes power_levels-Event (M_NOT_FOUND): Start mit leerem Event, '
'Schreiben nur wenn man sich selbst hoch genug setzt', () async {
final client = _FakeClient(
serverState: {},
getError: MatrixException.fromJson(
{'errcode': 'M_NOT_FOUND', 'error': 'Event not found.'}),
);
final room = _FakeRoom(client);
// Ohne eigenen Eintrag: users_default 0 < state_default-Fallback 50 → Abbruch.
await expectLater(
updatePowerLevelsSafely(room, (_) {}),
throwsException,
);
expect(client.written, isNull);
// Setzt man sich selbst auf 100, darf geschrieben werden.
await updatePowerLevelsSafely(room, (content) {
(content['users'] as Map<String, dynamic>)['@me:pyramid.example'] = 100;
});
final users = client.written!['users'] as Map;
expect(users['@me:pyramid.example'], 100);
});
test('andere Serverfehler (z. B. M_FORBIDDEN) werden durchgereicht, '
'nichts wird geschrieben', () async {
final client = _FakeClient(
serverState: {},
getError: MatrixException.fromJson(
{'errcode': 'M_FORBIDDEN', 'error': 'Nope.'}),
);
final room = _FakeRoom(client);
await expectLater(
updatePowerLevelsSafely(room, (_) {}),
throwsA(isA<MatrixException>()),
);
expect(client.written, isNull);
});
}
+67
View File
@@ -0,0 +1,67 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:matrix/matrix.dart';
import 'package:pyramid/core/soft_logout_guard.dart';
/// Fake-Client: nur `refreshAccessToken` ist implementiert. Jeder andere
/// SDK-Aufruf (insbesondere `logout()`/`clear()`!) schlägt laut mit
/// NoSuchMethodError fehl so würde der Test es sofort merken, wenn der
/// Guard je etwas anderes am Client aufriefe als den Refresh.
class _FakeClient implements Client {
_FakeClient(this._onRefresh);
final Future<void> Function(int attempt) _onRefresh;
int attempts = 0;
@override
Future<void> refreshAccessToken({Duration? customRefreshTokenLifetime}) {
attempts++;
return _onRefresh(attempts);
}
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
void main() {
test('erfolgreicher Refresh beim ersten Versuch: genau 1 Aufruf', () async {
final client = _FakeClient((_) async {});
await guardedSoftLogoutRefresh(client);
expect(client.attempts, 1);
});
test(
'transienter Fehler (z. B. Netz weg / Token-Rotations-Race mit dem '
'Push-Isolate): Retry heilt, kein Fehler nach außen',
() async {
final client = _FakeClient((attempt) async {
if (attempt == 1) throw Exception('Netzwerkfehler');
});
await guardedSoftLogoutRefresh(client);
expect(client.attempts, 2);
},
);
test(
'dauerhafter Fehler: genau 3 Versuche, und der Guard wirft NIE '
'(CLAUDE.md: Fehlerpfade dürfen nie in einem stillen Logout enden '
'ein Throw hier würde im SDK zu logout()+clear() eskalieren)',
() async {
final client = _FakeClient((_) async {
throw Exception('Server dauerhaft nicht erreichbar');
});
// Darf trotz 3 Fehlversuchen normal (ohne Exception) zurückkehren.
await guardedSoftLogoutRefresh(client);
expect(client.attempts, 3);
},
// Der Guard wartet real 2s+4s zwischen den Versuchen.
timeout: const Timeout(Duration(seconds: 30)),
);
test('auch ein Error (nicht nur Exception) im Refresh entkommt nie', () async {
final client = _FakeClient((_) async {
throw StateError('unerwarteter Zustand');
});
await guardedSoftLogoutRefresh(client);
expect(client.attempts, 3);
}, timeout: const Timeout(Duration(seconds: 30)));
}