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:
@@ -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: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/pyramid-arbeitsstand.md
|
||||||
2026-07-03 17:52 [Write] /home/steggi/.claude-pyramid/projects/-home-steggi-pyramid/memory/MEMORY.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
@@ -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
|
## 2026-07-03 – M1-Vorbereitung: SQLCipher-Migrationsplan + konsolidierter PC-Testplan
|
||||||
|
|
||||||
**Erledigt:** Nach Abschluss des Fable-5-Reviews (Abschnitt darunter) die zwei
|
**Erledigt:** Nach Abschluss des Fable-5-Reviews (Abschnitt darunter) die zwei
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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)));
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user