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
+3
View File
@@ -1199,3 +1199,6 @@
2026-07-03 17:51 [Write] /home/steggi/.claude-pyramid/projects/-home-steggi-pyramid/memory/pyramid_push.md
2026-07-03 17:52 [Write] /home/steggi/.claude-pyramid/projects/-home-steggi-pyramid/memory/pyramid-arbeitsstand.md
2026-07-03 17:52 [Write] /home/steggi/.claude-pyramid/projects/-home-steggi-pyramid/memory/MEMORY.md
2026-07-03 18:16 [Write] /home/steggi/pyramid/test/soft_logout_guard_test.dart
2026-07-03 18:17 [Write] /home/steggi/pyramid/test/power_levels_safety_test.dart
2026-07-03 22:21 [Edit] /home/steggi/pyramid/PROGRESS.md
+45
View File
@@ -13,6 +13,51 @@ Schritt (und beim Abbruch mitten im Schritt den Zwischenstand). Format:
---
## 2026-07-03 Erste Unit-Tests: Regressionsnetz für die zwei heiligsten Codepfade
**Erledigt:** Eine Vorsession hatte (unprotokolliert, Abbruch vor dem Testlauf
sichtbar nur am untrackten `test/`-Ordner + CHANGES.md-Hook-Log 18:16/18:17)
zwei Testdateien angelegt. Diese Session hat sie gegen den echten Code
gegengelesen, ausgeführt und committet damit hat das Projekt erstmals
überhaupt eine Testsuite, und zwar genau dort, wo CLAUDE.md „heilig" sagt:
- `test/soft_logout_guard_test.dart` (4 Tests): `guardedSoftLogoutRefresh`
wirft NIE nach außen (weder Exception noch Error) ein Throw würde im SDK
zu `logout()`+`clear()` eskalieren (der Uta-Bug). Geprüft: Erfolg beim
1. Versuch = genau 1 Aufruf, transienter Fehler wird per Retry geheilt,
dauerhafter Fehler = genau 3 Versuche + normale Rückkehr. Der Fake-Client
schlägt bei JEDEM anderen SDK-Aufruf laut fehl riefe der Guard je
`logout()`/`clear()` auf, fiele der Test sofort um.
- `test/power_levels_safety_test.dart` (6 Tests): `updatePowerLevelsSafely`
(Selbst-Aussperr-Schutz im Space-Admin) Server-Stand bleibt erhalten
(fremde users-Einträge überleben; genau der Bug, der schon mal Admins
ausgesperrt hat), Selbst-Degradierung bricht ab ohne zu schreiben,
users_default-Fallback, explizites `events[m.room.power_levels]`-
Erfordernis, M_NOT_FOUND-Start mit leerem Event, fremde Serverfehler
(M_FORBIDDEN) werden durchgereicht.
- `flutter test`: **alle 10 Tests grün auf dem Pi** (läuft headless, im
Gegensatz zu `flutter run` d. h. dieser Punkt ist NICHT „UNGETESTET (Pi)").
`flutter analyze` unverändert 1 bekannter Hinweis (`chat_provider.dart:42`).
- Wert fürs Refactoring: Beide Funktionen sind Vertragsgrenzen, die bei
M2-Umbauten (Call-Fassade, Storage) nicht wackeln dürfen künftige
Sessions sollten `flutter test` fest in Schritt 3 des Arbeitszyklus
aufnehmen (geht auch auf dem Pi).
**Offen/Nächster Schritt:** Unverändert: PC-Termin (docs/PC_TESTPLAN.md) oder
Bernds Antworten (Call-Pilot, SQLCipher-Priorität). Weitere Test-Kandidaten
scheitern derzeit an der Sichtbarkeit: `_detectType`, `_formatMessageTime`,
`_compareSpaceChildren`, die Megolm-Export-Kryptohelfer usw. sind alle
bibliotheksprivat testbar erst, wenn beim M2-Modulschnitt echte öffentliche
Schnittstellen entstehen (dann Tests gleich mitschreiben).
**Stolperfallen:** Die Vorsession wurde mitten im Schritt abgebrochen, OHNE
den Zwischenstand zu protokollieren (Pflicht laut CLAUDE.md) der untrackte
`test/`-Ordner war nur per `git status` auffindbar. Lehre: `git status` am
Sessionstart ernst nehmen; untrackte Dateien können unprotokollierte,
halbfertige Arbeit einer abgebrochenen Session sein.
---
## 2026-07-03 M1-Vorbereitung: SQLCipher-Migrationsplan + konsolidierter PC-Testplan
**Erledigt:** Nach Abschluss des Fable-5-Reviews (Abschnitt darunter) die zwei
+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)));
}