Files
Melo/test/settings/diagnose_opt_out_test.dart
Hermes (Server)andClaude Opus 5 bb800adb61 Datenschutz: Fehlerberichte nur mit Zustimmung senden (Opt-in)
Uebertragung eines Befunds aus dem Security-Audit der alten Melo-App
(21.08.2026) in die neue Codebasis.

Ausgangslage hier war eine ANDERE als dort: LoggerService lud jeden Fehler
unbedingt zu logs.baka-net.de hoch, es gab ueberhaupt keinen Schalter. In
der alten App existierte er zwar, wirkte aber nicht ueber einen Neustart
hinaus.

Neu:
- Schalter "Fehlerberichte senden" in den Einstellungen unter Datenschutz,
  Standard AUS. Ohne ausdrueckliche Zustimmung verlaesst kein Bericht das
  Geraet - auch nicht beim allerersten Start und auch nicht im Zeitfenster
  zwischen logger.init() und settings.init().
- Sperre an der einzigen Stelle, durch die jeder Upload laeuft. Lokales
  debugPrint bleibt, aber nichts verlaesst das Geraet; der Puffer wird
  geleert, damit er nicht unbegrenzt waechst.
- Die Entscheidung ueberlebt den Neustart: AppSettings speichert sie und
  reicht sie an den Logger durch, UND LoggerService liest sie beim Start
  zusaetzlich selbst.
- LoggerService bekam einen injizierbaren http.Client (Muster wie BakaAuth),
  damit sich belegen laesst, dass ohne Zustimmung KEIN Request rausgeht.

Auswirkung auf bestehende Installationen: Wer bisher unfreiwillig Berichte
gesendet hat, sendet ab diesem Update keine mehr, bis er zustimmt. Das ist
gewollt - die Zustimmung wurde nie eingeholt.

Nicht uebernommen: der zweite Befund (Logout liess Zugangsdaten zurueck)
trifft hier nicht zu. BakaAuth.abmelden() loescht genau die Schluessel, die
anmelden() schreibt, und Navidrome hat mit loescheZugangsdaten() ein
eigenes, vollstaendiges Trennen.

Auf main (34d6d8e) rebast. settings_screen.dart hat sich sauber automatisch
zusammengefuehrt - die Umbenennungen und MeloTheme-Farben der UI-Politur
bleiben erhalten, der Datenschutz-Abschnitt fuegt sich ein. Im CHANGELOG
kollidierten beide Seiten am Kopf; beide Eintraege wurden behalten.

Tests: 340 gruen (main allein: 328), 12 neue in
test/settings/diagnose_opt_out_test.dart. Fuenf tragende Stellen per
Mutationsprobe geprueft. flutter analyze: keine Befunde.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QbuWT267W8rFDsaavANuEn
2026-08-21 15:23:03 +02:00

207 lines
7.1 KiB
Dart
Raw Permalink 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.
import 'package:flutter_test/flutter_test.dart';
import 'package:http/testing.dart';
import 'package:http/http.dart' as http;
import 'package:melo/services/logger_service.dart';
import 'package:melo/settings/app_settings.dart';
import 'package:shared_preferences/shared_preferences.dart';
/// Übertragung eines Befunds aus dem Security-Audit der alten Melo-App
/// (21.08.2026): Dort stand die Diagnose-Einstellung in einer statischen
/// Variablen mit Default `true`, die beim Start nie aus den Einstellungen
/// gefüllt wurde — ein Opt-out überlebte keinen Neustart, während der
/// Schalter weiterhin "aus" anzeigte.
///
/// Hier wird festgehalten, dass genau das nicht passieren kann.
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
// Opt-in ist der Ruhezustand: nach jedem Test wieder aus.
tearDown(() => logger.sendeDiagnosedaten = false);
group('AppSettings.init', () {
test('übernimmt ein gespeichertes Opt-out und reicht es an den Logger', () async {
SharedPreferences.setMockInitialValues({LoggerService.diagnoseKey: false});
logger.sendeDiagnosedaten = true; // Zustand nach frischem App-Start
final settings = AppSettings();
await settings.init();
expect(settings.sendeDiagnosedaten, isFalse);
expect(logger.sendeDiagnosedaten, isFalse,
reason: 'Anzeige und Sendeverhalten dürfen nicht auseinanderlaufen');
});
test('ohne gespeicherte Entscheidung bleibt der Versand AUS (Opt-in)', () async {
SharedPreferences.setMockInitialValues({});
logger.sendeDiagnosedaten = true; // absichtlich "falsch" vorbelegt
final settings = AppSettings();
await settings.init();
expect(settings.sendeDiagnosedaten, isFalse,
reason: 'Ohne ausdrueckliche Zustimmung darf nichts gesendet werden');
expect(logger.sendeDiagnosedaten, isFalse);
});
test('eine gespeicherte Zustimmung wird respektiert', () async {
SharedPreferences.setMockInitialValues({LoggerService.diagnoseKey: true});
final settings = AppSettings();
await settings.init();
expect(settings.sendeDiagnosedaten, isTrue);
expect(logger.sendeDiagnosedaten, isTrue);
});
test('eine erteilte Zustimmung überlebt den Neustart', () async {
SharedPreferences.setMockInitialValues({});
final ersteSitzung = AppSettings();
await ersteSitzung.init();
await ersteSitzung.setSendeDiagnosedaten(true);
// Neustart: neue Instanzen, gleiche Ablage.
logger.sendeDiagnosedaten = false;
final zweiteSitzung = AppSettings();
await zweiteSitzung.init();
expect(zweiteSitzung.sendeDiagnosedaten, isTrue);
expect(logger.sendeDiagnosedaten, isTrue);
});
test('ein Widerruf überlebt den Neustart', () async {
SharedPreferences.setMockInitialValues({LoggerService.diagnoseKey: true});
final ersteSitzung = AppSettings();
await ersteSitzung.init();
await ersteSitzung.setSendeDiagnosedaten(false);
logger.sendeDiagnosedaten = true;
final zweiteSitzung = AppSettings();
await zweiteSitzung.init();
expect(zweiteSitzung.sendeDiagnosedaten, isFalse);
expect(logger.sendeDiagnosedaten, isFalse);
});
});
group('AppSettings.setSendeDiagnosedaten', () {
test('schaltet den Logger sofort um und speichert', () async {
SharedPreferences.setMockInitialValues({});
final settings = AppSettings();
await settings.init();
// Ausgangspunkt ist jetzt AUS (Opt-in), also zuerst zustimmen.
expect(settings.sendeDiagnosedaten, isFalse);
await settings.setSendeDiagnosedaten(true);
expect(logger.sendeDiagnosedaten, isTrue);
final prefs = await SharedPreferences.getInstance();
expect(prefs.getBool(LoggerService.diagnoseKey), isTrue);
await settings.setSendeDiagnosedaten(false);
expect(logger.sendeDiagnosedaten, isFalse);
expect(prefs.getBool(LoggerService.diagnoseKey), isFalse);
});
test('benachrichtigt Zuhörer', () async {
SharedPreferences.setMockInitialValues({});
final settings = AppSettings();
await settings.init();
var rufe = 0;
settings.addListener(() => rufe++);
await settings.setSendeDiagnosedaten(true);
await settings.setSendeDiagnosedaten(true); // unverändert → kein Ruf
expect(rufe, 1);
});
});
group('LoggerService Versand-Sperre', () {
/// Der Kern des Opt-outs: bei Widerspruch darf KEIN Request das Gerät
/// verlassen. Ohne diesen Test wäre genau die Sperre ungeprüft.
test('sendet nichts, wenn der Nutzer widersprochen hat', () async {
SharedPreferences.setMockInitialValues({LoggerService.diagnoseKey: false});
var requests = 0;
final dienst = LoggerService(
client: MockClient((_) async {
requests++;
return http.Response('{}', 200);
}),
);
await dienst.init();
await dienst.error('Absturz mit Details');
await dienst.flush();
expect(requests, 0, reason: 'Bei Opt-out darf nichts hochgeladen werden');
expect(dienst.logCount, 0, reason: 'Puffer darf nicht unbegrenzt wachsen');
});
test('sendet, wenn der Nutzer zugestimmt hat', () async {
SharedPreferences.setMockInitialValues({LoggerService.diagnoseKey: true});
var requests = 0;
final dienst = LoggerService(
client: MockClient((_) async {
requests++;
return http.Response('{}', 200);
}),
);
await dienst.init();
await dienst.error('Absturz mit Details');
expect(requests, greaterThan(0));
});
test('das Umschalten auf aus stoppt den Versand sofort', () async {
SharedPreferences.setMockInitialValues({LoggerService.diagnoseKey: true});
var requests = 0;
final dienst = LoggerService(
client: MockClient((_) async {
requests++;
return http.Response('{}', 200);
}),
);
await dienst.init();
dienst.sendeDiagnosedaten = false;
await dienst.error('Absturz nach dem Abschalten');
await dienst.flush();
expect(requests, 0);
});
});
group('LoggerService.init', () {
test('liest das Opt-out selbst, ohne auf AppSettings zu warten', () async {
// main() initialisiert den Logger VOR den Einstellungen — auch dieses
// Zeitfenster darf nichts senden.
SharedPreferences.setMockInitialValues({LoggerService.diagnoseKey: false});
final eigener = LoggerService();
await eigener.init();
expect(eigener.sendeDiagnosedaten, isFalse);
});
test('ein frischer Logger sendet ohne gespeicherte Zustimmung nicht', () async {
SharedPreferences.setMockInitialValues({});
var requests = 0;
final dienst = LoggerService(
client: MockClient((_) async {
requests++;
return http.Response('{}', 200);
}),
);
await dienst.init();
await dienst.error('Absturz beim allerersten Start');
await dienst.flush();
expect(dienst.sendeDiagnosedaten, isFalse);
expect(requests, 0,
reason: 'Erststart ohne Zustimmung darf nichts hochladen');
});
});
}