From 8e0e2d5f97ec1d9da92836b9c227b5eb2b923df0 Mon Sep 17 00:00:00 2001 From: Developer Date: Thu, 3 Sep 2026 08:44:25 -0400 Subject: [PATCH] feat: harden account lifecycle and cloud deletion --- cloudflare/motorsignal-r2-worker/src/index.ts | 21 ++++- lib/features/settings/account_screen.dart | 85 +++++++++++++++++++ .../auth/account_lifecycle_service.dart | 22 +++++ lib/services/auth/auth_providers.dart | 18 ++++ lib/services/auth/auth_service.dart | 58 +++++++++++++ lib/services/auth/auth_user.dart | 8 ++ .../cloud/account_cloud_cleanup_service.dart | 63 ++++++++++++++ lib/services/github/github_auth_service.dart | 56 +++++++++--- lib/services/osm_oauth_service.dart | 24 ++++-- lib/services/sync/sync_providers.dart | 14 +++ pubspec.lock | 48 +++++++++++ pubspec.yaml | 1 + test/account_lifecycle_test.dart | 76 +++++++++++++++++ 13 files changed, 477 insertions(+), 17 deletions(-) create mode 100644 lib/services/auth/account_lifecycle_service.dart create mode 100644 lib/services/cloud/account_cloud_cleanup_service.dart create mode 100644 test/account_lifecycle_test.dart diff --git a/cloudflare/motorsignal-r2-worker/src/index.ts b/cloudflare/motorsignal-r2-worker/src/index.ts index 2b065af..17b43a2 100644 --- a/cloudflare/motorsignal-r2-worker/src/index.ts +++ b/cloudflare/motorsignal-r2-worker/src/index.ts @@ -15,11 +15,12 @@ export interface Env { interface FirebaseTokenPayload extends JWTPayload { user_id?: string; email?: string; + firebase?: { sign_in_provider?: string }; } const CORS_HEADERS = { 'Access-Control-Allow-Origin': '*', - 'Access-Control-Allow-Methods': 'GET, POST, PUT, OPTIONS', + 'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS', 'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-Remote-Ride-Id, X-Bundle-Sha256, X-App-Version', 'Access-Control-Expose-Headers': 'ETag, Content-Disposition, X-MotorSignal-Ride-Id, X-MotorSignal-Sha256', }; @@ -48,6 +49,24 @@ export default { if (!authUser?.sub) return jsonResponse({ error: 'Invalid, expired, or unauthorized Firebase ID token' }, 401); const authenticatedUid = authUser.sub; + if (request.method === 'DELETE' && path === '/account/cloud-data') { + if (authUser.firebase?.sign_in_provider === 'anonymous') { + return jsonResponse({ error: 'Permanent account required' }, 403); + } + const prefix = `users/${authenticatedUid}/`; + let cursor: string | undefined; + let deleted = 0; + do { + const listed = await env.R2_BUCKET.list({ prefix, cursor }); + if (listed.objects.length > 0) { + await env.R2_BUCKET.delete(listed.objects.map((object) => object.key)); + deleted += listed.objects.length; + } + cursor = listed.truncated ? listed.cursor : undefined; + } while (cursor); + return jsonResponse({ success: true, deletedObjects: deleted }); + } + if (request.method === 'PUT' && path === '/r2/upload') { const remoteRideId = request.headers.get('X-Remote-Ride-Id'); if (!remoteRideId || !isValidRideId(remoteRideId)) return jsonResponse({ error: 'Invalid or missing X-Remote-Ride-Id header' }, 400); diff --git a/lib/features/settings/account_screen.dart b/lib/features/settings/account_screen.dart index 6c49727..4e5b6d9 100644 --- a/lib/features/settings/account_screen.dart +++ b/lib/features/settings/account_screen.dart @@ -584,6 +584,24 @@ class _AccountScreenState extends ConsumerState { 'Last Sign-In', user.lastSignInTime!.toLocal().toString().split('.')[0], ), + if (user.providerId == 'password' && !user.emailVerified) ...[ + const SizedBox(height: 12), + Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: Colors.amberAccent.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.amberAccent.withValues(alpha: 0.5)), + ), + child: Row( + children: [ + const Expanded(child: Text('Your email is not verified. Verify it to protect account recovery.', style: TextStyle(fontSize: 11, color: Colors.white))), + TextButton(onPressed: () => _sendVerification(), child: const Text('VERIFY')), + IconButton(tooltip: 'Refresh verification status', onPressed: () => _reloadUser(), icon: const Icon(Icons.refresh, size: 18)), + ], + ), + ), + ], const SizedBox(height: 24), SizedBox( width: double.infinity, @@ -608,6 +626,16 @@ class _AccountScreenState extends ConsumerState { ref.read(authNotifierProvider.notifier).signOut(), ), ), + const SizedBox(height: 10), + SizedBox( + width: double.infinity, + child: TextButton.icon( + style: TextButton.styleFrom(foregroundColor: Colors.redAccent), + icon: const Icon(Icons.delete_forever_outlined, size: 18), + label: const Text('DELETE ACCOUNT'), + onPressed: () => _handleDeleteAccount(user), + ), + ), ], ), ), @@ -1081,6 +1109,63 @@ class _AccountScreenState extends ConsumerState { ); } + Future _sendVerification() async { + try { + await ref.read(authNotifierProvider.notifier).sendEmailVerification(); + if (mounted) ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Verification email sent.'))); + } catch (e) { + if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Could not send verification email: $e'))); + } + } + + Future _reloadUser() async { + try { + await ref.read(authNotifierProvider.notifier).reloadCurrentUser(); + } catch (e) { + if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Could not refresh account status: $e'))); + } + } + + Future _handleDeleteAccount(AuthUser user) async { + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Delete Account'), + content: const Text('This permanently deletes your MotorCloud backups and account. Local rides and garage data stay on this device. You will need to reauthenticate before deletion.'), + actions: [ + TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('CANCEL')), + FilledButton(onPressed: () => Navigator.pop(context, true), child: const Text('DELETE ACCOUNT')), + ], + ), + ) ?? false; + if (!confirmed || !mounted) return; + + String? password; + if (user.providerId == 'password') { + final controller = TextEditingController(); + password = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Confirm Your Password'), + content: TextField(controller: controller, obscureText: true, autofocus: true, decoration: const InputDecoration(labelText: 'Current password')), + actions: [ + TextButton(onPressed: () => Navigator.pop(context), child: const Text('CANCEL')), + FilledButton(onPressed: () => Navigator.pop(context, controller.text), child: const Text('CONTINUE')), + ], + ), + ); + controller.dispose(); + if (password == null || password.isEmpty || !mounted) return; + } + + try { + await ref.read(accountLifecycleServiceProvider).deleteAccount(password: password); + if (mounted) ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Account deleted. Local rides remain on this device.'))); + } catch (e) { + if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Account deletion was not completed: $e'))); + } + } + Future _handleBackupAllHistoricalRides() async { final db = ref.read(databaseProvider); final syncManager = ref.read(syncManagerProvider); diff --git a/lib/services/auth/account_lifecycle_service.dart b/lib/services/auth/account_lifecycle_service.dart new file mode 100644 index 0000000..49e1d61 --- /dev/null +++ b/lib/services/auth/account_lifecycle_service.dart @@ -0,0 +1,22 @@ +import '../../database/database.dart'; +import '../cloud/account_cloud_cleanup_service.dart'; +import 'auth_service.dart'; + +/// Coordinates destructive account work while preserving device-local rides. +class AccountLifecycleService { + final AuthService _auth; + final CloudAccountCleanup _cloud; + final AppDatabase _db; + + AccountLifecycleService({required this._auth, required this._cloud, required this._db}); + + Future deleteAccount({String? password}) async { + final user = _auth.currentUser; + if (user == null || user.isAnonymous) throw StateError('A permanent account is required.'); + final uid = user.uid; + await _auth.reauthenticate(password: password); + await _cloud.deleteCloudData(); + await _auth.deleteCurrentUser(); + await _db.clearCloudOwnerUid(uid); + } +} diff --git a/lib/services/auth/auth_providers.dart b/lib/services/auth/auth_providers.dart index a60e134..d60c032 100644 --- a/lib/services/auth/auth_providers.dart +++ b/lib/services/auth/auth_providers.dart @@ -56,6 +56,24 @@ class AuthNotifier extends StateNotifier { state = _authService.currentState; } + Future sendEmailVerification() async { + await _authService.sendEmailVerification(); + state = _authService.currentState; + } + + Future reloadCurrentUser() async { + await _authService.reloadCurrentUser(); + state = _authService.currentState; + } + + Future reauthenticate({String? password}) => + _authService.reauthenticate(password: password); + + Future deleteCurrentUser() async { + await _authService.deleteCurrentUser(); + state = _authService.currentState; + } + Future signOut() async { await _authService.signOut(); state = _authService.currentState; diff --git a/lib/services/auth/auth_service.dart b/lib/services/auth/auth_service.dart index 2ec3504..1ce9461 100644 --- a/lib/services/auth/auth_service.dart +++ b/lib/services/auth/auth_service.dart @@ -13,12 +13,17 @@ abstract class AuthService { AuthState get currentState; AuthUser? get currentUser; bool get isConfigured; + bool get isEmailVerified => false; Future signInWithEmailAndPassword(String email, String password); Future createUserWithEmailAndPassword(String email, String password, {String? displayName}); Future signInWithGoogle(); Future signInAnonymously(); Future sendPasswordResetEmail(String email); + Future sendEmailVerification() async => throw UnsupportedError('Email verification is not supported by this auth service.'); + Future reloadCurrentUser() async => throw UnsupportedError('User reload is not supported by this auth service.'); + Future reauthenticate({String? password}) async => throw UnsupportedError('Reauthentication is not supported by this auth service.'); + Future deleteCurrentUser() async => throw UnsupportedError('Account deletion is not supported by this auth service.'); Future getIdToken([bool forceRefresh = false]); Future signOut(); void dispose(); @@ -125,6 +130,7 @@ class FirebaseAuthService implements AuthService { displayName: user.displayName, photoUrl: user.photoURL, isAnonymous: user.isAnonymous, + emailVerified: user.emailVerified, providerId: provider, createdAt: user.metadata.creationTime, lastSignInTime: user.metadata.lastSignInTime, @@ -367,6 +373,58 @@ class FirebaseAuthService implements AuthService { } } + @override + bool get isEmailVerified => _firebaseAuth?.currentUser?.emailVerified ?? false; + + @override + Future sendEmailVerification() async { + final user = _firebaseAuth?.currentUser; + if (user == null || user.isAnonymous) throw StateError('A permanent account is required.'); + await user.sendEmailVerification(); + } + + @override + Future reloadCurrentUser() async { + final user = _firebaseAuth?.currentUser; + if (user == null) throw StateError('No signed-in account.'); + await user.reload(); + final refreshed = _firebaseAuth?.currentUser; + if (refreshed != null) { + refreshed.isAnonymous + ? _updateState(AuthState.anonymous(_mapFirebaseUser(refreshed), isFirebaseConfigured: true)) + : _updateAuthenticatedUser(refreshed); + } + } + + @override + Future reauthenticate({String? password}) async { + final auth = _firebaseAuth; + final user = auth?.currentUser; + if (auth == null || user == null || user.isAnonymous) throw StateError('A permanent account is required.'); + final providers = user.providerData.map((p) => p.providerId).toSet(); + fb.AuthCredential credential; + if (providers.contains('password')) { + if (password == null || password.isEmpty || user.email == null) throw StateError('Enter your current password.'); + credential = fb.EmailAuthProvider.credential(email: user.email!, password: password); + } else if (providers.contains('google.com')) { + final googleUser = await (_googleSignIn ?? GoogleSignIn()).signIn(); + if (googleUser == null) throw StateError('Google reauthentication was cancelled.'); + final googleAuth = await googleUser.authentication; + credential = fb.GoogleAuthProvider.credential(accessToken: googleAuth.accessToken, idToken: googleAuth.idToken); + } else { + throw StateError('This account provider cannot reauthenticate here.'); + } + await user.reauthenticateWithCredential(credential); + } + + @override + Future deleteCurrentUser() async { + final user = _firebaseAuth?.currentUser; + if (user == null || user.isAnonymous) throw StateError('A permanent account is required.'); + await user.delete(); + _updateState(const AuthState.unauthenticated(isFirebaseConfigured: true)); + } + @override Future getIdToken([bool forceRefresh = false]) async { final auth = _firebaseAuth; diff --git a/lib/services/auth/auth_user.dart b/lib/services/auth/auth_user.dart index c0ddf34..2ed0b36 100644 --- a/lib/services/auth/auth_user.dart +++ b/lib/services/auth/auth_user.dart @@ -9,6 +9,7 @@ class AuthUser { final String? displayName; final String? photoUrl; final bool isAnonymous; + final bool emailVerified; final String? providerId; final DateTime? createdAt; final DateTime? lastSignInTime; @@ -19,6 +20,7 @@ class AuthUser { this.displayName, this.photoUrl, this.isAnonymous = false, + this.emailVerified = false, this.providerId, this.createdAt, this.lastSignInTime, @@ -41,6 +43,7 @@ class AuthUser { 'displayName': displayName, 'photoUrl': photoUrl, 'isAnonymous': isAnonymous, + 'emailVerified': emailVerified, 'providerId': providerId, 'createdAt': createdAt?.toIso8601String(), 'lastSignInTime': lastSignInTime?.toIso8601String(), @@ -52,6 +55,7 @@ class AuthUser { displayName: json['displayName'] as String?, photoUrl: json['photoUrl'] as String?, isAnonymous: json['isAnonymous'] as bool? ?? false, + emailVerified: json['emailVerified'] as bool? ?? false, providerId: json['providerId'] as String?, createdAt: json['createdAt'] != null ? DateTime.tryParse(json['createdAt'] as String) @@ -67,6 +71,7 @@ class AuthUser { String? displayName, String? photoUrl, bool? isAnonymous, + bool? emailVerified, String? providerId, DateTime? createdAt, DateTime? lastSignInTime, @@ -77,6 +82,7 @@ class AuthUser { displayName: displayName ?? this.displayName, photoUrl: photoUrl ?? this.photoUrl, isAnonymous: isAnonymous ?? this.isAnonymous, + emailVerified: emailVerified ?? this.emailVerified, providerId: providerId ?? this.providerId, createdAt: createdAt ?? this.createdAt, lastSignInTime: lastSignInTime ?? this.lastSignInTime, @@ -92,6 +98,7 @@ class AuthUser { email == other.email && displayName == other.displayName && isAnonymous == other.isAnonymous && + emailVerified == other.emailVerified && providerId == other.providerId; @override @@ -100,6 +107,7 @@ class AuthUser { email.hashCode ^ displayName.hashCode ^ isAnonymous.hashCode ^ + emailVerified.hashCode ^ providerId.hashCode; @override diff --git a/lib/services/cloud/account_cloud_cleanup_service.dart b/lib/services/cloud/account_cloud_cleanup_service.dart new file mode 100644 index 0000000..291ad12 --- /dev/null +++ b/lib/services/cloud/account_cloud_cleanup_service.dart @@ -0,0 +1,63 @@ +import 'package:cloud_firestore/cloud_firestore.dart'; +import 'package:firebase_core/firebase_core.dart'; +import 'package:http/http.dart' as http; +import '../auth/auth_service.dart'; +import '../sync/r2_upload_service.dart'; + +abstract class CloudAccountCleanup { + Future deleteCloudData(); +} + +class AccountCloudCleanupService implements CloudAccountCleanup { + final AuthService _auth; + final FirebaseFirestore? _firestore; + final http.Client _client; + final String _workerUrl; + + AccountCloudCleanupService({ + required this._auth, + FirebaseFirestore? firestore, + http.Client? client, + String? workerUrl, + }) : _firestore = firestore ?? _resolveFirestore(), + _client = client ?? http.Client(), + _workerUrl = (workerUrl ?? R2UploadService.defaultWorkerUrl).replaceAll(RegExp(r'/+$'), ''); + + static FirebaseFirestore? _resolveFirestore() { + try { + if (Firebase.apps.isNotEmpty) return FirebaseFirestore.instance; + } catch (_) {} + return null; + } + + @override + Future deleteCloudData() async { + final user = _auth.currentUser; + if (user == null || user.isAnonymous) throw StateError('A permanent account is required.'); + final token = await _auth.getIdToken(true); + if (token == null || token.isEmpty) throw StateError('A valid account session is required.'); + + final response = await _client.delete( + Uri.parse('$_workerUrl/account/cloud-data'), + headers: {'Authorization': 'Bearer ${token.trim()}'}, + ).timeout(const Duration(seconds: 30)); + if (response.statusCode < 200 || response.statusCode >= 300) { + throw StateError('MotorCloud cleanup was not completed (${response.statusCode}).'); + } + + final firestore = _firestore; + if (firestore == null) throw StateError('MotorCloud database is unavailable.'); + final userRef = firestore.collection('users').doc(user.uid); + final rides = await userRef.collection('rides').get(); + final motorcycles = await userRef.collection('motorcycles').get(); + final batch = firestore.batch(); + for (final doc in rides.docs) { + batch.delete(doc.reference); + } + for (final doc in motorcycles.docs) { + batch.delete(doc.reference); + } + batch.delete(userRef); + await batch.commit(); + } +} diff --git a/lib/services/github/github_auth_service.dart b/lib/services/github/github_auth_service.dart index 81a3f6a..6915ab6 100644 --- a/lib/services/github/github_auth_service.dart +++ b/lib/services/github/github_auth_service.dart @@ -2,6 +2,7 @@ import 'dart:convert'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:http/http.dart' as http; import 'package:shared_preferences/shared_preferences.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import '../../features/records/records_service.dart'; /// Secure credentials and repository configuration for GitHub ride sync @@ -15,29 +16,64 @@ class GitHubAuthService { final SharedPreferences _prefs; final http.Client _client; + final FlutterSecureStorage _secureStorage; + String? _token; - GitHubAuthService(this._prefs, [http.Client? client]) : _client = client ?? http.Client(); + GitHubAuthService(this._prefs, [http.Client? client, FlutterSecureStorage? secureStorage]) + : _client = client ?? http.Client(), + _secureStorage = secureStorage ?? const FlutterSecureStorage() { + _migrateToken(); + } + + Future _migrateToken() async { + try { + final secure = await _secureStorage.read(key: _keyToken); + if (secure != null && secure.isNotEmpty) { + _token = secure; + return; + } + final legacy = _prefs.getString(_keyToken); + if (legacy == null || legacy.isEmpty) return; + final decoded = _decodeLegacy(legacy); + await _secureStorage.write(key: _keyToken, value: decoded); + await _prefs.remove(_keyToken); + _token = decoded; + } catch (_) {} + } + + String _decodeLegacy(String raw) { + try { return utf8.decode(base64.decode(raw)); } catch (_) { return raw; } + } /// Reads stored Personal Access Token String? getToken() { - final raw = _prefs.getString(_keyToken); - if (raw == null || raw.isEmpty) return null; - try { - return utf8.decode(base64.decode(raw)); - } catch (_) { - return raw; - } + if (_token != null && _token!.isNotEmpty) return _token; + final legacy = _prefs.getString(_keyToken); + if (legacy == null || legacy.isEmpty) return null; + // Legacy values are read only to bridge an upgrade; constructor migration + // moves them to secure storage and removes the old entry. + return _decodeLegacy(legacy); } /// Stores Personal Access Token securely Future saveToken(String token) async { - final encoded = base64.encode(utf8.encode(token.trim())); - await _prefs.setString(_keyToken, encoded); + _token = token.trim(); + try { + await _secureStorage.write(key: _keyToken, value: _token); + await _prefs.remove(_keyToken); + } catch (_) { + // Native secure storage is unavailable in plugin-less test environments; + // keep the token only in memory rather than persist it insecurely. + } } /// Clears stored token Future clearToken() async { await _prefs.remove(_keyToken); + try { + await _secureStorage.delete(key: _keyToken); + } catch (_) {} + _token = null; } /// Target GitHub repository (default: bponders12-cell/ride_logs) diff --git a/lib/services/osm_oauth_service.dart b/lib/services/osm_oauth_service.dart index e7f699d..e340bf3 100644 --- a/lib/services/osm_oauth_service.dart +++ b/lib/services/osm_oauth_service.dart @@ -5,6 +5,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:http/http.dart' as http; import 'package:shared_preferences/shared_preferences.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:url_launcher/url_launcher.dart'; class OsmUserProfile { @@ -79,6 +80,8 @@ class OsmOAuthService extends StateNotifier { static const String _prefTokenKey = 'osm_access_token'; static const String _prefUserKey = 'osm_user_profile'; static const String _prefVerifierKey = 'osm_pkce_code_verifier'; + static const String _secureTokenKey = 'osm_access_token'; + final FlutterSecureStorage _secureStorage = const FlutterSecureStorage(); String? _currentCodeVerifier; String? _inFlightCode; @@ -92,7 +95,14 @@ class OsmOAuthService extends StateNotifier { Future _loadStoredSession() async { try { final prefs = await SharedPreferences.getInstance(); - final token = prefs.getString(_prefTokenKey); + var token = await _secureStorage.read(key: _secureTokenKey); + if (token == null || token.isEmpty) { + token = prefs.getString(_prefTokenKey); + if (token != null && token.isNotEmpty) { + await _secureStorage.write(key: _secureTokenKey, value: token); + await prefs.remove(_prefTokenKey); + } + } final userJsonStr = prefs.getString(_prefUserKey); if (token != null && token.isNotEmpty) { @@ -225,7 +235,7 @@ class OsmOAuthService extends StateNotifier { return false; } - debugPrint('[OsmOAuth] Exchanging token with code: $code, redirect: $redirectUri'); + debugPrint('[OsmOAuth] Exchanging authorization code'); final response = await http.post( Uri.parse(tokenEndpoint), headers: { @@ -240,13 +250,14 @@ class OsmOAuthService extends StateNotifier { }, ); - debugPrint('[OsmOAuth] Token response (${response.statusCode}): ${response.body}'); + debugPrint('[OsmOAuth] Token exchange response: ${response.statusCode}'); if (response.statusCode == 200) { final json = jsonDecode(response.body) as Map; final accessToken = json['access_token'] as String; - await prefs.setString(_prefTokenKey, accessToken); + await _secureStorage.write(key: _secureTokenKey, value: accessToken); + await prefs.remove(_prefTokenKey); await prefs.remove(_prefVerifierKey); _currentCodeVerifier = null; @@ -261,7 +272,7 @@ class OsmOAuthService extends StateNotifier { } else { state = state.copyWith( isLoading: false, - errorMessage: 'Token exchange failed (${response.statusCode}): ${response.body}', + errorMessage: 'Token exchange failed (${response.statusCode}).', ); return false; } @@ -343,6 +354,7 @@ class OsmOAuthService extends StateNotifier { Future logout() async { final prefs = await SharedPreferences.getInstance(); await prefs.remove(_prefTokenKey); + await _secureStorage.delete(key: _secureTokenKey); await prefs.remove(_prefUserKey); await prefs.remove(_prefVerifierKey); state = const OsmAuthState(isAuthenticated: false, isLoading: false); @@ -352,4 +364,4 @@ class OsmOAuthService extends StateNotifier { final osmOAuthServiceProvider = StateNotifierProvider((ref) { return OsmOAuthService(); -}); \ No newline at end of file +}); diff --git a/lib/services/sync/sync_providers.dart b/lib/services/sync/sync_providers.dart index 4f6aefd..f1417eb 100644 --- a/lib/services/sync/sync_providers.dart +++ b/lib/services/sync/sync_providers.dart @@ -4,6 +4,8 @@ import '../../features/records/records_service.dart'; import '../auth/auth_providers.dart'; import '../auth/auth_state.dart'; import '../cloud/firebase_account_sync_service.dart'; +import '../cloud/account_cloud_cleanup_service.dart'; +import '../auth/account_lifecycle_service.dart'; import '../github/github_auth_service.dart'; import '../github/github_sync_service.dart'; import 'firebase_sync_provider.dart'; @@ -64,6 +66,18 @@ final firebaseAccountSyncServiceProvider = Provider( ), ); +final accountCloudCleanupServiceProvider = Provider( + (ref) => AccountCloudCleanupService(auth: ref.watch(authServiceProvider)), +); + +final accountLifecycleServiceProvider = Provider( + (ref) => AccountLifecycleService( + auth: ref.watch(authServiceProvider), + cloud: ref.watch(accountCloudCleanupServiceProvider), + db: ref.watch(databaseProvider), + ), +); + final githubSyncProvider = Provider( (ref) => GitHubSyncProvider( service: ref.watch(githubSyncServiceProvider), diff --git a/pubspec.lock b/pubspec.lock index cadb57f..91689d6 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -467,6 +467,54 @@ packages: url: "https://pub.dev" source: hosted version: "2.6.1" + flutter_secure_storage: + dependency: "direct main" + description: + name: flutter_secure_storage + sha256: "7686b1d6a29985dcbb808c59518226e603e3bfa7c0ddfd1a0d00e4cda77c868e" + url: "https://pub.dev" + source: hosted + version: "10.3.1" + flutter_secure_storage_darwin: + dependency: transitive + description: + name: flutter_secure_storage_darwin + sha256: "82329fa5cdf343773b1b6897dea959105a29f092454259edff92f9f6637e8149" + url: "https://pub.dev" + source: hosted + version: "0.3.2" + flutter_secure_storage_linux: + dependency: transitive + description: + name: flutter_secure_storage_linux + sha256: "76fa9c841b3b1619fc5b5bc36efc7d158fa2356f223b6caeb1d0c80a54168546" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + flutter_secure_storage_platform_interface: + dependency: transitive + description: + name: flutter_secure_storage_platform_interface + sha256: "788060052712555182aba55ecb5f8b6e5cb9cfe8f776c83249a61fe3ce877db4" + url: "https://pub.dev" + source: hosted + version: "2.0.3" + flutter_secure_storage_web: + dependency: transitive + description: + name: flutter_secure_storage_web + sha256: "073a62b3aeb866ab4ce795f960413948e51e5a42a9b0c8333b6daf5bb3208a1c" + url: "https://pub.dev" + source: hosted + version: "2.1.1" + flutter_secure_storage_windows: + dependency: transitive + description: + name: flutter_secure_storage_windows + sha256: "471951813a97006d899db4948acc654a4f28c440083ea08178935ce20b173ec1" + url: "https://pub.dev" + source: hosted + version: "4.2.2" flutter_test: dependency: "direct dev" description: flutter diff --git a/pubspec.yaml b/pubspec.yaml index 0e564e5..fcd52fa 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -63,6 +63,7 @@ dependencies: firebase_auth: ^5.5.1 google_sign_in: ^6.2.2 cloud_firestore: ^5.6.5 + flutter_secure_storage: ^10.2.1 dev_dependencies: flutter_test: diff --git a/test/account_lifecycle_test.dart b/test/account_lifecycle_test.dart new file mode 100644 index 0000000..085ceaf --- /dev/null +++ b/test/account_lifecycle_test.dart @@ -0,0 +1,76 @@ +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:motorcycle_dash/database/database.dart'; +import 'package:motorcycle_dash/services/auth/account_lifecycle_service.dart'; +import 'package:motorcycle_dash/services/auth/auth_service.dart'; +import 'package:motorcycle_dash/services/auth/auth_state.dart'; +import 'package:motorcycle_dash/services/auth/auth_user.dart'; +import 'package:motorcycle_dash/services/cloud/account_cloud_cleanup_service.dart'; + +class _Auth implements AuthService { + AuthUser? user = const AuthUser(uid: 'user_a', email: 'a@test.com'); + bool reauthenticated = false; + bool deleted = false; + @override bool get isConfigured => true; + @override bool get isEmailVerified => true; + @override AuthUser? get currentUser => user; + @override AuthState get currentState => AuthState.authenticated(user!); + @override Stream get authStateChanges => Stream.value(currentState); + @override Future createUserWithEmailAndPassword(String e, String p, {String? displayName}) async {} + @override Future sendPasswordResetEmail(String email) async {} + @override Future sendEmailVerification() async {} + @override Future reloadCurrentUser() async {} + @override Future signInAnonymously() async {} + @override Future signInWithEmailAndPassword(String e, String p) async {} + @override Future signInWithGoogle() async {} + @override Future getIdToken([bool forceRefresh = false]) async => 'token'; + @override Future reauthenticate({String? password}) async { reauthenticated = true; } + @override Future deleteCurrentUser() async { deleted = true; user = null; } + @override Future signOut() async {} + @override void dispose() {} +} + +class _Cloud implements CloudAccountCleanup { + bool fail = false; + bool called = false; + @override Future deleteCloudData() async { called = true; if (fail) throw StateError('network'); } +} + +void main() { + test('successful deletion preserves rides and clears only deleted owner', () async { + final db = AppDatabase.forTesting(NativeDatabase.memory()); + addTearDown(db.close); + final owned = await db.startRide(); + final other = await db.startRide(); + await db.claimRideForCloudOwner(owned, 'user_a'); + await db.claimRideForCloudOwner(other, 'user_b'); + final auth = _Auth(); + final cloud = _Cloud(); + + await AccountLifecycleService(auth: auth, cloud: cloud, db: db).deleteAccount(password: 'secret'); + + expect(auth.reauthenticated, isTrue); + expect(cloud.called, isTrue); + expect(auth.deleted, isTrue); + expect(await db.getCloudOwnerUid(owned), isNull); + expect(await db.getCloudOwnerUid(other), 'user_b'); + expect(await db.getCloudRideId(owned), isNotNull); + expect((await db.select(db.rides).get()).length, 2); + }); + + test('cloud failure does not delete auth or clear ownership', () async { + final db = AppDatabase.forTesting(NativeDatabase.memory()); + addTearDown(db.close); + final ride = await db.startRide(); + await db.claimRideForCloudOwner(ride, 'user_a'); + final auth = _Auth(); + final cloud = _Cloud()..fail = true; + + await expectLater( + AccountLifecycleService(auth: auth, cloud: cloud, db: db).deleteAccount(), + throwsStateError, + ); + expect(auth.deleted, isFalse); + expect(await db.getCloudOwnerUid(ride), 'user_a'); + }); +}