diff --git a/CHANGES.md b/CHANGES.md index c388ece..610c0ee 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -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 diff --git a/PROGRESS.md b/PROGRESS.md index f04b351..635563f 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -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 diff --git a/test/power_levels_safety_test.dart b/test/power_levels_safety_test.dart new file mode 100644 index 0000000..f695f85 --- /dev/null +++ b/test/power_levels_safety_test.dart @@ -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 serverState; + MatrixException? getError; + Map? written; + + @override + String? get userID => '@me:pyramid.example'; + + @override + Future> getRoomStateWithKey( + String roomId, + String eventType, + String stateKey, { + Format? format, + }) async { + final err = getError; + if (err != null) throw err; + return serverState; + } + + @override + Future setRoomStateWithKey( + String roomId, + String eventType, + String stateKey, + Map 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)['@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)['@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) + .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)['@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()), + ); + expect(client.written, isNull); + }); +} diff --git a/test/soft_logout_guard_test.dart b/test/soft_logout_guard_test.dart new file mode 100644 index 0000000..926071f --- /dev/null +++ b/test/soft_logout_guard_test.dart @@ -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 Function(int attempt) _onRefresh; + int attempts = 0; + + @override + Future 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))); +}