## Testbarkeit (kein neues Package) - CloudService: http.Client injizierbar (Konstruktor-Parameter, Default unverändert) - AuthService: http.Client + TokenSpeicher injizierbar; SecureTokenSpeicher kapselt FlutterSecureStorage, @visibleForTesting AuthService.fuerTests - Alle HTTP-Calls über _client statt Top-Level http.get/post ## test/cloud_service_test.dart (22 Tests) - login: 200/401/Netzwerkfehler - verbinde() ohne Benutzer → Status fehler, kein Request - statusDaten: 200/401(Auth-Fehler)/Netzwerkfehler - syncChanges LWW-Konflikt-Pfade: updated_at bleibt für Client-Vergleich erhalten, leere/fehlende Changes, 401 → [], Netzwerkfehler → [], since-Body - syncAll (LWW-Schreibpfad): 200/401 - Playlists, Favoriten, History, listSongs, delete, globalList inkl. Fehlerpfade ## test/auth_service_test.dart (19 Tests) - initialisieren: 200 → eingeloggt, 401 → Logout + Speicher gelöscht, Netzwerkfehler → Offline-Trust, 5xx → Token bleibt, ohne Token → kein Request - tokenPruefen: 200/401/Netzwerkfehler/503 - login: ok mit Persistenz, 401, 200-ohne-Token (Fall-Through-Regression), Netzwerkfehler - logout löscht Speicher, authHeader mit/ohne Bearer
222 lines
7.1 KiB
Dart
222 lines
7.1 KiB
Dart
import 'dart:convert';
|
||
import 'package:http/http.dart' as http;
|
||
import 'package:flutter/foundation.dart';
|
||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||
import '../config/app_config.dart';
|
||
import '../services/melo_logger.dart';
|
||
|
||
/// Minimal-Speicherabstraktion für die Token-Persistenz — macht den Service
|
||
/// in Tests mit einem In-Memory-Fake testbar (kein Platform-Channel nötig).
|
||
abstract class TokenSpeicher {
|
||
Future<String?> lesen(String key);
|
||
Future<void> schreiben(String key, String? wert);
|
||
Future<void> loeschen(String key);
|
||
}
|
||
|
||
/// Produktions-Implementierung auf FlutterSecureStorage.
|
||
class SecureTokenSpeicher implements TokenSpeicher {
|
||
final FlutterSecureStorage _secure = const FlutterSecureStorage();
|
||
|
||
@override
|
||
Future<String?> lesen(String key) => _secure.read(key: key);
|
||
|
||
@override
|
||
Future<void> schreiben(String key, String? wert) =>
|
||
_secure.write(key: key, value: wert);
|
||
|
||
@override
|
||
Future<void> loeschen(String key) => _secure.delete(key: key);
|
||
}
|
||
|
||
/// Baka-Auth Service – JWT-basierte Authentifizierung (v3 — Persistent Login)
|
||
/// Integriert mit https://baka-net.de/auth
|
||
class AuthService {
|
||
static final AuthService _instance = AuthService._();
|
||
factory AuthService() => _instance;
|
||
AuthService._({http.Client? client, TokenSpeicher? speicher})
|
||
: _client = client ?? http.Client(),
|
||
_speicher = speicher ?? SecureTokenSpeicher();
|
||
|
||
/// Nur für Tests: frische Instanz mit injizierbaren Abhängigkeiten.
|
||
@visibleForTesting
|
||
AuthService.fuerTests({http.Client? client, TokenSpeicher? speicher})
|
||
: this._(client: client, speicher: speicher);
|
||
|
||
final http.Client _client;
|
||
final TokenSpeicher _speicher;
|
||
|
||
String? _token;
|
||
String _user = '';
|
||
bool _initialisiert = false;
|
||
|
||
bool get istEingeloggt => _token != null && _token!.isNotEmpty;
|
||
String get benutzer => _user;
|
||
String? get token => _token;
|
||
|
||
/// Auth-Header für API-Requests
|
||
Map<String, String> get authHeader {
|
||
final headers = <String, String>{
|
||
'Content-Type': 'application/json',
|
||
};
|
||
if (_token != null && _token!.isNotEmpty) {
|
||
headers['Authorization'] = 'Bearer $_token';
|
||
}
|
||
return headers;
|
||
}
|
||
|
||
/// Lädt gespeicherten Token beim App-Start und validiert ihn
|
||
Future<bool> initialisieren() async {
|
||
if (_initialisiert) return istEingeloggt;
|
||
try {
|
||
_token = await _speicher.lesen('baka_token');
|
||
_user = await _speicher.lesen('baka_user') ?? '';
|
||
|
||
if (_token != null && _token!.isNotEmpty) {
|
||
// Token beim Server validieren (nicht blind vertrauen)
|
||
final valid = await _tokenOnlinePruefen();
|
||
if (!valid) {
|
||
// Token ist abgelaufen — nicht als eingeloggt behandeln
|
||
MeloLogger().zustand('auth_token_expired', {'user': _user});
|
||
_token = null;
|
||
_user = '';
|
||
await _speicher.loeschen('baka_token');
|
||
await _speicher.loeschen('baka_user');
|
||
} else {
|
||
MeloLogger().zustand('auth_restored', {'user': _user});
|
||
}
|
||
}
|
||
} catch (e) {
|
||
MeloLogger().fehler('auth_init', e);
|
||
}
|
||
_initialisiert = true;
|
||
return istEingeloggt;
|
||
}
|
||
|
||
/// Prüft Token beim Baka-Auth-Server (online)
|
||
Future<bool> _tokenOnlinePruefen() async {
|
||
if (_token == null) return false;
|
||
try {
|
||
final response = await _client
|
||
.get(
|
||
Uri.parse('${AppConfig.authUrl}/verify'),
|
||
headers: authHeader,
|
||
)
|
||
.timeout(const Duration(seconds: 5));
|
||
if (response.statusCode == 200) return true;
|
||
// Nur bei explizitem 401 (abgelaufen/ungültig) den Token löschen.
|
||
// Bei 5xx, anderen Fehlern oder temporären Server-Problemen Token behalten.
|
||
if (response.statusCode == 401) return false;
|
||
return true;
|
||
} catch (_) {
|
||
// Bei Netzwerkfehler: Token lokal akzeptieren (Offline-Modus)
|
||
return true;
|
||
}
|
||
}
|
||
|
||
/// Login über Baka-Auth-Server
|
||
Future<AuthResult> login(String user, String password) async {
|
||
try {
|
||
final response = await _client
|
||
.post(
|
||
Uri.parse('${AppConfig.authUrl}/login'),
|
||
headers: {'Content-Type': 'application/json'},
|
||
body: jsonEncode({
|
||
'username': user,
|
||
'password': password,
|
||
}),
|
||
)
|
||
.timeout(const Duration(seconds: 10));
|
||
|
||
if (response.statusCode == 200) {
|
||
final data = jsonDecode(response.body);
|
||
_token = data['token'] as String?;
|
||
_user = user;
|
||
|
||
if (_token != null && _token!.isNotEmpty) {
|
||
await _speicher.schreiben('baka_token', _token!);
|
||
await _speicher.schreiben('baka_user', _user);
|
||
MeloLogger().aktion('auth_login_ok', {'user': _user});
|
||
return AuthResult.ok;
|
||
}
|
||
final serverMsg = data['message'] as String? ??
|
||
data['error'] as String? ??
|
||
'Unbekannt';
|
||
return AuthResult.fehlgeschlagen(serverMsg);
|
||
}
|
||
|
||
String fehler = 'Login fehlgeschlagen (${response.statusCode})';
|
||
MeloLogger().fehler('auth_login_fail', fehler);
|
||
return AuthResult.fehlgeschlagen(fehler);
|
||
} catch (e) {
|
||
final msg = 'Keine Verbindung zum Auth-Server';
|
||
MeloLogger().fehler('auth_login_error', e);
|
||
return AuthResult.fehlgeschlagen(msg);
|
||
}
|
||
}
|
||
|
||
/// Registrierung über Baka-Auth-Server
|
||
Future<AuthResult> registrieren(
|
||
String user, String password, String email) async {
|
||
try {
|
||
final response = await _client
|
||
.post(
|
||
Uri.parse('${AppConfig.authUrl}/register'),
|
||
headers: {'Content-Type': 'application/json'},
|
||
body: jsonEncode({
|
||
'username': user,
|
||
'password': password,
|
||
'email': email,
|
||
}),
|
||
)
|
||
.timeout(const Duration(seconds: 10));
|
||
|
||
if (response.statusCode == 200 || response.statusCode == 201) {
|
||
final data = jsonDecode(response.body);
|
||
_token = data['token'] as String?;
|
||
_user = user;
|
||
|
||
if (_token != null && _token!.isNotEmpty) {
|
||
await _speicher.schreiben('baka_token', _token!);
|
||
await _speicher.schreiben('baka_user', _user);
|
||
MeloLogger().aktion('auth_register_ok', {'user': _user});
|
||
return AuthResult.ok;
|
||
}
|
||
}
|
||
|
||
String fehler = 'Registrierung fehlgeschlagen';
|
||
try {
|
||
final data = jsonDecode(response.body);
|
||
fehler = data['error'] as String? ?? fehler;
|
||
} catch (_) {}
|
||
return AuthResult.fehlgeschlagen(fehler);
|
||
} catch (e) {
|
||
return AuthResult.fehlgeschlagen('Keine Verbindung zum Server');
|
||
}
|
||
}
|
||
|
||
/// Token beim Server validieren (public)
|
||
Future<bool> tokenPruefen() async {
|
||
return _tokenOnlinePruefen();
|
||
}
|
||
|
||
/// Ausloggen – Token löschen
|
||
Future<void> logout() async {
|
||
_token = null;
|
||
_user = '';
|
||
await _speicher.loeschen('baka_token');
|
||
await _speicher.loeschen('baka_user');
|
||
MeloLogger().aktion('auth_logout', {});
|
||
}
|
||
}
|
||
|
||
/// Ergebnis eines Auth-Versuchs
|
||
class AuthResult {
|
||
final bool erfolg;
|
||
final String? fehler;
|
||
|
||
const AuthResult._(this.erfolg, this.fehler);
|
||
|
||
static const ok = AuthResult._(true, null);
|
||
static AuthResult fehlgeschlagen(String msg) => AuthResult._(false, msg);
|
||
}
|