From 885d5626901ce5e4558b4a7a0aed5f0ab8f9b7af Mon Sep 17 00:00:00 2001 From: Developer Date: Thu, 3 Sep 2026 16:49:36 -0400 Subject: [PATCH] fix: harden account deletion lifecycle and cloud cleanup --- firebase.emulator.json | 10 + .../emulator-tests/firestore.rules.test.js | 107 ++++++++- firebase/emulator-tests/package.json | 2 +- firestore.rules | 5 +- lib/features/settings/account_screen.dart | 103 ++++++--- .../auth/account_lifecycle_service.dart | 45 +++- lib/services/auth/auth_service.dart | 2 + .../cloud/account_cloud_cleanup_service.dart | 45 +++- test/account_cloud_cleanup_service_test.dart | 202 ++++++++++++++++ ...ccount_deletion_lifecycle_widget_test.dart | 218 ++++++++++++++++++ 10 files changed, 703 insertions(+), 36 deletions(-) create mode 100644 firebase.emulator.json create mode 100644 test/account_cloud_cleanup_service_test.dart create mode 100644 test/account_deletion_lifecycle_widget_test.dart diff --git a/firebase.emulator.json b/firebase.emulator.json new file mode 100644 index 0000000..00f87bb --- /dev/null +++ b/firebase.emulator.json @@ -0,0 +1,10 @@ +{ + "firestore": { + "rules": "firestore.rules" + }, + "emulators": { + "firestore": { + "port": 8088 + } + } +} diff --git a/firebase/emulator-tests/firestore.rules.test.js b/firebase/emulator-tests/firestore.rules.test.js index b46e98f..801f103 100644 --- a/firebase/emulator-tests/firestore.rules.test.js +++ b/firebase/emulator-tests/firestore.rules.test.js @@ -1,10 +1,10 @@ -import { afterAll, beforeAll, describe, expect, test } from 'vitest'; +import { afterAll, afterEach, beforeAll, describe, expect, test } from 'vitest'; import { assertFails, assertSucceeds, initializeTestEnvironment, } from '@firebase/rules-unit-testing'; -import { doc, getDoc, setDoc, updateDoc } from 'firebase/firestore'; +import { collection, deleteDoc, doc, getDoc, getDocs, setDoc, updateDoc } from 'firebase/firestore'; import fs from 'node:fs'; import path from 'node:path'; @@ -24,6 +24,71 @@ afterAll(async () => { await testEnv?.cleanup(); }); +afterEach(async () => { + await testEnv?.clearFirestore(); +}); + +const cleanupCollections = [ + { + name: 'rides/{rideId}/events', + collectionPath: 'users/alice/rides/ride_cleanup/events', + documentPath: 'users/alice/rides/ride_cleanup/events/event_cleanup', + }, + { + name: 'rides/{rideId}/performance', + collectionPath: 'users/alice/rides/ride_cleanup/performance', + documentPath: 'users/alice/rides/ride_cleanup/performance/performance_cleanup', + }, + { + name: 'rides', + collectionPath: 'users/alice/rides', + documentPath: 'users/alice/rides/ride_cleanup', + }, + { + name: 'motorcycles', + collectionPath: 'users/alice/motorcycles', + documentPath: 'users/alice/motorcycles/motorcycle_cleanup', + }, + { + name: 'trips', + collectionPath: 'users/alice/trips', + documentPath: 'users/alice/trips/trip_cleanup', + }, + { + name: 'events', + collectionPath: 'users/alice/events', + documentPath: 'users/alice/events/event_cleanup', + }, + { + name: 'friends', + collectionPath: 'users/alice/friends', + documentPath: 'users/alice/friends/friend_cleanup', + }, +]; + +async function seedCleanupTree() { + await testEnv.withSecurityRulesDisabled(async (context) => { + const db = context.firestore(); + await setDoc(doc(db, 'users/alice'), { id: 'alice' }); + await setDoc(doc(db, 'users/alice/rides/ride_cleanup'), { + id: 'ride_cleanup', + ownerId: 'alice', + }); + await setDoc(doc(db, 'users/alice/rides/ride_cleanup/events/event_cleanup'), { + type: 'ride-event', + }); + await setDoc(doc(db, 'users/alice/rides/ride_cleanup/performance/performance_cleanup'), { + type: 'performance-run', + }); + await setDoc(doc(db, 'users/alice/motorcycles/motorcycle_cleanup'), { + ownerId: 'alice', + }); + await setDoc(doc(db, 'users/alice/trips/trip_cleanup'), { type: 'trip' }); + await setDoc(doc(db, 'users/alice/events/event_cleanup'), { type: 'event' }); + await setDoc(doc(db, 'users/alice/friends/friend_cleanup'), { type: 'friend' }); + }); +} + describe('MotorCloud ride ownership rules', () => { test('a signed-in owner can create and read their own ride', async () => { const alice = testEnv.authenticatedContext('alice').firestore(); @@ -82,4 +147,42 @@ describe('MotorCloud ride ownership rules', () => { await assertFails(getDoc(ride)); }); + + test('an owner can list and delete every account-cleanup path', async () => { + await seedCleanupTree(); + const alice = testEnv.authenticatedContext('alice').firestore(); + + await assertSucceeds(getDoc(doc(alice, 'users/alice'))); + for (const entry of cleanupCollections) { + await assertSucceeds(getDocs(collection(alice, entry.collectionPath))); + } + for (const entry of cleanupCollections) { + await assertSucceeds(deleteDoc(doc(alice, entry.documentPath))); + } + await assertSucceeds(deleteDoc(doc(alice, 'users/alice'))); + }); + + test('another user cannot list or delete any account-cleanup path', async () => { + await seedCleanupTree(); + const bob = testEnv.authenticatedContext('bob').firestore(); + + await assertFails(getDoc(doc(bob, 'users/alice'))); + await assertFails(deleteDoc(doc(bob, 'users/alice'))); + for (const entry of cleanupCollections) { + await assertFails(getDocs(collection(bob, entry.collectionPath))); + await assertFails(deleteDoc(doc(bob, entry.documentPath))); + } + }); + + test('signed-out callers cannot list or delete account-cleanup paths', async () => { + await seedCleanupTree(); + const signedOut = testEnv.unauthenticatedContext().firestore(); + + await assertFails(getDoc(doc(signedOut, 'users/alice'))); + await assertFails(deleteDoc(doc(signedOut, 'users/alice'))); + for (const entry of cleanupCollections) { + await assertFails(getDocs(collection(signedOut, entry.collectionPath))); + await assertFails(deleteDoc(doc(signedOut, entry.documentPath))); + } + }); }); diff --git a/firebase/emulator-tests/package.json b/firebase/emulator-tests/package.json index c94e72d..8bb13c2 100644 --- a/firebase/emulator-tests/package.json +++ b/firebase/emulator-tests/package.json @@ -4,7 +4,7 @@ "type": "module", "scripts": { "test": "vitest run", - "test:emulator": "firebase emulators:exec --project motorsignal-emulator --only firestore \"npm test\"" + "test:emulator": "firebase --config ../../firebase.emulator.json emulators:exec --project motorsignal-emulator --only firestore \"npm test\"" }, "devDependencies": { "@firebase/rules-unit-testing": "^5.0.2", diff --git a/firestore.rules b/firestore.rules index b582f58..cdfbd48 100644 --- a/firestore.rules +++ b/firestore.rules @@ -50,7 +50,10 @@ service cloud.firestore { match /rides/{rideId} { allow create: if validRideCreate(userId, rideId); allow update: if validRideUpdate(userId); - allow read, delete: if ownsUser(userId) && resource.data.ownerId == userId; + // Listing is path-scoped to the authenticated owner. Individual + // reads and deletes retain the persisted ownerId check. + allow list: if ownsUser(userId); + allow get, delete: if ownsUser(userId) && resource.data.ownerId == userId; match /events/{eventId} { allow read, write: if ownsUser(userId); diff --git a/lib/features/settings/account_screen.dart b/lib/features/settings/account_screen.dart index 4e5b6d9..58d9623 100644 --- a/lib/features/settings/account_screen.dart +++ b/lib/features/settings/account_screen.dart @@ -17,6 +17,12 @@ class AccountScreen extends ConsumerStatefulWidget { enum _AuthFormMode { signIn, register, forgotPassword } +class _DeleteAccountDialogResult { + final String? password; + + const _DeleteAccountDialogResult({this.password}); +} + class _AccountScreenState extends ConsumerState { final _formKey = GlobalKey(); final _emailController = TextEditingController(); @@ -30,6 +36,7 @@ class _AccountScreenState extends ConsumerState { @override void dispose() { + debugPrint('[DELETE_FLOW] AccountScreen dispose'); _emailController.dispose(); _passwordController.dispose(); _displayNameController.dispose(); @@ -1127,42 +1134,86 @@ class _AccountScreenState extends ConsumerState { } Future _handleDeleteAccount(AuthUser user) async { - final confirmed = await showDialog( + debugPrint('[DELETE_FLOW] delete dialog opened'); + final controller = TextEditingController(); + final formKey = GlobalKey(); + final result = await showDialog<_DeleteAccountDialogResult>( context: context, - builder: (context) => AlertDialog( + builder: (dialogContext) => 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.'), + content: Form( + key: formKey, + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('Deleting your account will remove your MotorCloud backup data and account identity.'), + const SizedBox(height: 12), + const Text('Rides, telemetry/history, and garage data stored on this device will remain available.'), + if (user.providerId == 'password') ...[ + const SizedBox(height: 16), + TextFormField( + controller: controller, + obscureText: true, + autofocus: true, + decoration: const InputDecoration(labelText: 'Current password'), + validator: (value) => value == null || value.isEmpty + ? 'Enter your current password' + : null, + ), + ], + ], + ), + ), + ), actions: [ - TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('CANCEL')), - FilledButton(onPressed: () => Navigator.pop(context, true), child: const Text('DELETE ACCOUNT')), + TextButton( + onPressed: () { + Navigator.of(dialogContext).pop(); + }, + child: const Text('CANCEL'), + ), + FilledButton( + onPressed: () { + if (user.providerId == 'password' && + !(formKey.currentState?.validate() ?? false)) { + return; + } + debugPrint('[DELETE_FLOW] delete dialog submitted'); + Navigator.of(dialogContext).pop( + _DeleteAccountDialogResult( + password: user.providerId == 'password' + ? controller.text + : null, + ), + ); + }, + child: const Text('DELETE ACCOUNT'), + ), ], ), - ) ?? false; - if (!confirmed || !mounted) return; + ); + controller.dispose(); - 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; + if (!mounted || result == null) { + if (mounted) debugPrint('[DELETE_FLOW] delete dialog cancelled'); + 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.'))); + await ref.read(accountLifecycleServiceProvider).deleteAccount(password: result.password); } catch (e) { - if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Account deletion was not completed: $e'))); + debugPrint('[DELETE_FLOW] deletion failure class=${e.runtimeType} mounted=$mounted'); + if (mounted) { + ScaffoldMessenger.maybeOf(context)?.showSnackBar( + const SnackBar( + content: Text( + 'Account deletion could not be completed. Your account and local rides are unchanged.', + ), + ), + ); + } } } diff --git a/lib/services/auth/account_lifecycle_service.dart b/lib/services/auth/account_lifecycle_service.dart index 49e1d61..644d0bb 100644 --- a/lib/services/auth/account_lifecycle_service.dart +++ b/lib/services/auth/account_lifecycle_service.dart @@ -1,3 +1,4 @@ +import 'package:flutter/foundation.dart'; import '../../database/database.dart'; import '../cloud/account_cloud_cleanup_service.dart'; import 'auth_service.dart'; @@ -14,9 +15,45 @@ class AccountLifecycleService { 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); + var stage = 'reauth'; + try { + debugPrint('[DELETE_FLOW] reauth start'); + await _auth.reauthenticate(password: password); + debugPrint('[DELETE_FLOW] reauth success'); + stage = 'cloud cleanup'; + debugPrint('[DELETE_FLOW] cloud cleanup start'); + await _cloud.deleteCloudData(); + debugPrint('[DELETE_FLOW] cloud cleanup success'); + stage = 'Firebase deletion'; + debugPrint('[DELETE_FLOW] auth delete start'); + await _auth.deleteCurrentUser(); + debugPrint('[DELETE_FLOW] auth delete success'); + stage = 'local ownership cleanup'; + debugPrint('[DELETE_FLOW] local ownership clear start'); + await _db.clearCloudOwnerUid(uid); + debugPrint('[DELETE_FLOW] local ownership clear success'); + } catch (error, stackTrace) { + final message = error.toString().replaceAll( + RegExp(r'Bearer\s+\S+', caseSensitive: false), + 'Bearer [REDACTED]', + ); + final clippedMessage = message.length > 240 + ? message.substring(0, 240) + : message; + final appFrames = stackTrace + .toString() + .split('\n') + .where((line) => line.contains('package:motorcycle_dash/')) + .take(5) + .join(' | '); + debugPrint( + '[DELETE_FLOW] failure stage=$stage type=${error.runtimeType} ' + 'message=$clippedMessage', + ); + if (appFrames.isNotEmpty) { + debugPrint('[DELETE_FLOW] failure frames=$appFrames'); + } + rethrow; + } } } diff --git a/lib/services/auth/auth_service.dart b/lib/services/auth/auth_service.dart index 1ce9461..dd2f5d9 100644 --- a/lib/services/auth/auth_service.dart +++ b/lib/services/auth/auth_service.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'package:flutter/foundation.dart'; import 'package:firebase_core/firebase_core.dart'; import 'package:firebase_auth/firebase_auth.dart' as fb; import 'package:google_sign_in/google_sign_in.dart'; @@ -87,6 +88,7 @@ class FirebaseAuthService implements AuthService { _fbAuthSubscription = auth.authStateChanges().listen( (fb.User? user) { if (user == null) { + debugPrint('[DELETE_FLOW] auth state callback signedOut'); _updateState(const AuthState.unauthenticated(isFirebaseConfigured: true)); } else { final appUser = _mapFirebaseUser(user); diff --git a/lib/services/cloud/account_cloud_cleanup_service.dart b/lib/services/cloud/account_cloud_cleanup_service.dart index c603d64..e8b14b0 100644 --- a/lib/services/cloud/account_cloud_cleanup_service.dart +++ b/lib/services/cloud/account_cloud_cleanup_service.dart @@ -1,5 +1,8 @@ +import 'dart:convert'; + import 'package:cloud_firestore/cloud_firestore.dart'; import 'package:firebase_core/firebase_core.dart'; +import 'package:flutter/foundation.dart'; import 'package:http/http.dart' as http; import '../auth/auth_service.dart'; import '../sync/r2_upload_service.dart'; @@ -36,13 +39,30 @@ class AccountCloudCleanupService implements CloudAccountCleanup { 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 endpoint = Uri.tryParse('$_workerUrl/account/cloud-data'); + if (endpoint == null || !endpoint.hasScheme || + (endpoint.scheme != 'http' && endpoint.scheme != 'https')) { + throw StateError('MotorCloud cleanup endpoint is unavailable.'); + } final response = await _client.delete( - Uri.parse('$_workerUrl/account/cloud-data'), + endpoint, 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 detail = _safeResponseDetail(response.body); + debugPrint( + '[DELETE_FLOW] cloud cleanup response status=${response.statusCode} ' + 'body=$detail', + ); + throw StateError( + 'MotorCloud cleanup was not completed (${response.statusCode}).', + ); + } + if (response.statusCode != 204 && !_isSuccessfulWorkerResponse(response.body)) { + final detail = _safeResponseDetail(response.body); + debugPrint('[DELETE_FLOW] cloud cleanup response malformed body=$detail'); + throw StateError('MotorCloud cleanup returned an invalid response.'); } final firestore = _firestore; @@ -76,4 +96,25 @@ class AccountCloudCleanupService implements CloudAccountCleanup { await batch.commit(); } } + + String _safeResponseDetail(String body) { + final sanitized = body + .replaceAll(RegExp(r'Bearer\s+\S+', caseSensitive: false), 'Bearer [REDACTED]') + .replaceAll(RegExp(r'"(?:token|password|secret|pat)"\s*:\s*"[^"]*"', caseSensitive: false), '"credential":"[REDACTED]"'); + final compact = sanitized.replaceAll(RegExp(r'\s+'), ' ').trim(); + if (compact.isEmpty) return '[empty]'; + return compact.length > 240 ? compact.substring(0, 240) : compact; + } + + bool _isSuccessfulWorkerResponse(String body) { + try { + final decoded = jsonDecode(body); + return decoded is Map && + decoded['success'] == true && + decoded['deletedObjects'] is int && + (decoded['deletedObjects'] as int) >= 0; + } on FormatException { + return false; + } + } } diff --git a/test/account_cloud_cleanup_service_test.dart b/test/account_cloud_cleanup_service_test.dart new file mode 100644 index 0000000..7a5dcca --- /dev/null +++ b/test/account_cloud_cleanup_service_test.dart @@ -0,0 +1,202 @@ +import 'dart:async'; + +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:flutter_test/flutter_test.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 { + final _states = StreamController.broadcast(); + String? token = 'test-token'; + AuthUser? user = const AuthUser( + uid: 'cleanup-test', + email: 'cleanup@test.com', + providerId: 'password', + emailVerified: true, + ); + + @override + Stream get authStateChanges => _states.stream; + @override + AuthState get currentState => user == null + ? const AuthState.unauthenticated(isFirebaseConfigured: true) + : AuthState.authenticated(user!, isFirebaseConfigured: true); + @override + AuthUser? get currentUser => user; + @override + bool get isConfigured => true; + @override + bool get isEmailVerified => true; + @override + Future reauthenticate({String? password}) async {} + @override + Future deleteCurrentUser() async {} + @override + Future getIdToken([bool forceRefresh = false]) async => token; + @override + Future signInWithEmailAndPassword(String email, String password) async {} + @override + Future createUserWithEmailAndPassword(String email, String password, {String? displayName}) async {} + @override + Future signInWithGoogle() async {} + @override + Future signInAnonymously() async {} + @override + Future sendPasswordResetEmail(String email) async {} + @override + Future sendEmailVerification() async {} + @override + Future reloadCurrentUser() async {} + @override + Future signOut() async {} + @override + void dispose() => _states.close(); +} + +AccountCloudCleanupService _service(_Auth auth, int status, String body) { + return AccountCloudCleanupService( + auth: auth, + client: MockClient((request) async { + expect(request.method, 'DELETE'); + expect(request.url.path, '/account/cloud-data'); + expect(request.headers['authorization'], 'Bearer test-token'); + return http.Response(body, status); + }), + workerUrl: 'https://cleanup.test', + ); +} + +void main() { + test('accepts a valid Worker 200 response before Firestore cleanup', () async { + final auth = _Auth(); + addTearDown(auth.dispose); + + await expectLater( + _service(auth, 200, '{"success":true,"deletedObjects":0}').deleteCloudData(), + throwsA(isA().having( + (error) => error.message, + 'message', + 'MotorCloud database is unavailable.', + )), + ); + }); + + test('accepts an idempotent empty-prefix 204 response', () async { + final auth = _Auth(); + addTearDown(auth.dispose); + + await expectLater( + _service(auth, 204, '').deleteCloudData(), + throwsA(isA().having( + (error) => error.message, + 'message', + 'MotorCloud database is unavailable.', + )), + ); + }); + + test('rejects a malformed Worker success response', () async { + final auth = _Auth(); + addTearDown(auth.dispose); + + await expectLater( + _service(auth, 200, '{"deleted":0}').deleteCloudData(), + throwsA(isA().having( + (error) => error.message, + 'message', + 'MotorCloud cleanup returned an invalid response.', + )), + ); + }); + + test('rejects an empty Worker success response', () async { + final auth = _Auth(); + addTearDown(auth.dispose); + + await expectLater( + _service(auth, 200, '').deleteCloudData(), + throwsA(isA().having( + (error) => error.message, + 'message', + 'MotorCloud cleanup returned an invalid response.', + )), + ); + }); + + for (final status in [401, 403, 500]) { + test('surfaces Worker HTTP $status as controlled cleanup failure', () async { + final auth = _Auth(); + addTearDown(auth.dispose); + + await expectLater( + _service(auth, status, '{"error":"failure"}').deleteCloudData(), + throwsA(isA().having( + (error) => error.message, + 'message', + 'MotorCloud cleanup was not completed ($status).', + )), + ); + }); + } + + test('rejects an empty token before making a Worker request', () async { + final auth = _Auth()..token = null; + addTearDown(auth.dispose); + + await expectLater( + _service(auth, 200, '').deleteCloudData(), + throwsA(isA().having( + (error) => error.message, + 'message', + 'A valid account session is required.', + )), + ); + }); + + test('rejects an anonymous account before making a Worker request', () async { + final auth = _Auth() + ..user = const AuthUser(uid: 'guest', isAnonymous: true); + addTearDown(auth.dispose); + var requests = 0; + final service = AccountCloudCleanupService( + auth: auth, + client: MockClient((request) async { + requests++; + return http.Response('', 500); + }), + workerUrl: 'https://cleanup.test', + ); + + await expectLater( + service.deleteCloudData(), + throwsA(isA().having( + (error) => error.message, + 'message', + 'A permanent account is required.', + )), + ); + expect(requests, 0); + }); + + test('rejects invalid Worker configuration before making a request', () async { + final auth = _Auth(); + addTearDown(auth.dispose); + final service = AccountCloudCleanupService( + auth: auth, + client: MockClient((request) async => http.Response('', 200)), + workerUrl: '', + ); + + await expectLater( + service.deleteCloudData(), + throwsA(isA().having( + (error) => error.message, + 'message', + 'MotorCloud cleanup endpoint is unavailable.', + )), + ); + }); +} diff --git a/test/account_deletion_lifecycle_widget_test.dart b/test/account_deletion_lifecycle_widget_test.dart new file mode 100644 index 0000000..c93bc0d --- /dev/null +++ b/test/account_deletion_lifecycle_widget_test.dart @@ -0,0 +1,218 @@ +import 'dart:async'; + +import 'package:cloud_firestore/cloud_firestore.dart'; +import 'package:drift/native.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:motorcycle_dash/database/database.dart'; +import 'package:motorcycle_dash/features/settings/account_screen.dart'; +import 'package:motorcycle_dash/services/auth/auth_providers.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/auth/account_lifecycle_service.dart'; +import 'package:motorcycle_dash/services/cloud/account_cloud_cleanup_service.dart'; +import 'package:motorcycle_dash/services/sync/sync_providers.dart'; +import 'package:motorcycle_dash/features/records/records_service.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class _FakeAuth implements AuthService { + final _states = StreamController.broadcast(); + AuthUser? user = const AuthUser(uid: 'delete-test', email: 'delete@test.com', providerId: 'password', emailVerified: true); + bool reauthenticated = false; + bool deleted = false; + Object? reauthError; + + @override + Stream get authStateChanges => _states.stream; + @override + AuthState get currentState => user == null + ? const AuthState.unauthenticated(isFirebaseConfigured: true) + : AuthState.authenticated(user!, isFirebaseConfigured: true); + @override + AuthUser? get currentUser => user; + @override + bool get isConfigured => true; + @override + bool get isEmailVerified => true; + @override + Future reauthenticate({String? password}) async { + if (reauthError != null) throw reauthError!; + reauthenticated = true; + } + @override + Future deleteCurrentUser() async { + deleted = true; + user = null; + _states.add(currentState); + } + @override + Future getIdToken([bool forceRefresh = false]) async => 'test-token'; + @override + Future signInWithEmailAndPassword(String email, String password) async {} + @override + Future createUserWithEmailAndPassword(String email, String password, {String? displayName}) async {} + @override + Future signInWithGoogle() async {} + @override + Future signInAnonymously() async {} + @override + Future sendPasswordResetEmail(String email) async {} + @override + Future sendEmailVerification() async {} + @override + Future reloadCurrentUser() async {} + @override + Future signOut() async {} + @override + void dispose() => _states.close(); +} + +class _FakeCloud implements CloudAccountCleanup { + bool called = false; + Object? error; + @override + Future deleteCloudData() async { + called = true; + if (error != null) throw error!; + } +} + +Future _pumpAccountScreen( + WidgetTester tester, + _FakeAuth auth, + _FakeCloud cloud, +) async { + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + final db = AppDatabase.forTesting(NativeDatabase.memory()); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + sharedPreferencesProvider.overrideWithValue(prefs), + databaseProvider.overrideWithValue(db), + authServiceProvider.overrideWithValue(auth), + accountLifecycleServiceProvider.overrideWithValue( + AccountLifecycleService(auth: auth, cloud: cloud, db: db), + ), + ], + child: const MaterialApp(home: AccountScreen()), + ), + ); + await tester.pumpAndSettle(); + return db; +} + +void main() { + testWidgets( + 'delete account uses one password dialog without lifecycle assertion', + (tester) async { + final auth = _FakeAuth(); + final cloud = _FakeCloud(); + final db = await _pumpAccountScreen(tester, auth, cloud); + addTearDown(() async { + auth.dispose(); + await db.close(); + }); + + expect(find.text('DELETE ACCOUNT'), findsOneWidget); + await tester.tap(find.text('DELETE ACCOUNT').last); + await tester.pump(); + expect(find.text('Delete Account'), findsOneWidget); + expect(find.byType(AlertDialog), findsOneWidget); + await tester.enterText(find.byType(TextField), 'test-password'); + await tester.tap(find.text('DELETE ACCOUNT').last); + await tester.pump(); + await tester.pump(); + await tester.pumpAndSettle(); + + expect(auth.reauthenticated, isTrue); + expect(cloud.called, isTrue); + expect(auth.deleted, isTrue); + expect(find.text('Account & Cloud Identity'), findsOneWidget); + expect(find.text('SIGN IN TO MOTORCLOUD'), findsOneWidget); + expect(find.text('Account deleted. Local rides remain on this device.'), findsNothing); + expect(find.text('Confirm Your Password'), findsNothing); + expect(tester.takeException(), isNull); + }, + ); + + testWidgets('canceling account deletion performs no lifecycle work', (tester) async { + final auth = _FakeAuth(); + final cloud = _FakeCloud(); + final db = await _pumpAccountScreen(tester, auth, cloud); + addTearDown(() async { + auth.dispose(); + await db.close(); + }); + + await tester.tap(find.text('DELETE ACCOUNT').last); + await tester.pump(); + await tester.tap(find.text('CANCEL').last); + await tester.pumpAndSettle(); + + expect(auth.reauthenticated, isFalse); + expect(auth.deleted, isFalse); + expect(cloud.called, isFalse); + expect(find.byType(AlertDialog), findsNothing); + expect(tester.takeException(), isNull); + }); + + testWidgets('failed password reauthentication stays lifecycle safe', (tester) async { + final auth = _FakeAuth()..reauthError = StateError('invalid password'); + final cloud = _FakeCloud(); + final db = await _pumpAccountScreen(tester, auth, cloud); + addTearDown(() async { + auth.dispose(); + await db.close(); + }); + + await tester.tap(find.text('DELETE ACCOUNT').last); + await tester.pump(); + await tester.enterText(find.byType(TextField), 'wrong-password'); + await tester.tap(find.text('DELETE ACCOUNT').last); + await tester.pumpAndSettle(); + + expect(auth.reauthenticated, isFalse); + expect(auth.deleted, isFalse); + expect(cloud.called, isFalse); + expect( + find.text('Account deletion could not be completed. Your account and local rides are unchanged.'), + findsOneWidget, + ); + expect(tester.takeException(), isNull); + }); + + testWidgets('cloud cleanup failure stays controlled and preserves session', (tester) async { + final auth = _FakeAuth(); + final cloud = _FakeCloud() + ..error = FirebaseException( + plugin: 'cloud_firestore', + code: 'permission-denied', + message: 'cleanup unavailable', + ); + final db = await _pumpAccountScreen(tester, auth, cloud); + addTearDown(() async { + auth.dispose(); + await db.close(); + }); + + await tester.tap(find.text('DELETE ACCOUNT').last); + await tester.pump(); + await tester.enterText(find.byType(TextField), 'test-password'); + await tester.tap(find.text('DELETE ACCOUNT').last); + await tester.pumpAndSettle(); + + expect(auth.reauthenticated, isTrue); + expect(auth.deleted, isFalse); + expect(cloud.called, isTrue); + expect(auth.currentUser, isNotNull); + expect( + find.text('Account deletion could not be completed. Your account and local rides are unchanged.'), + findsOneWidget, + ); + expect(tester.takeException(), isNull); + }); +}