fix: harden account deletion lifecycle and cloud cleanup

This commit is contained in:
Developer 2026-09-03 16:49:36 -04:00
parent cdcbfbf108
commit 885d562690
10 changed files with 703 additions and 36 deletions

10
firebase.emulator.json Normal file
View File

@ -0,0 +1,10 @@
{
"firestore": {
"rules": "firestore.rules"
},
"emulators": {
"firestore": {
"port": 8088
}
}
}

View File

@ -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)));
}
});
});

View File

@ -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",

View File

@ -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);

View File

@ -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<AccountScreen> {
final _formKey = GlobalKey<FormState>();
final _emailController = TextEditingController();
@ -30,6 +36,7 @@ class _AccountScreenState extends ConsumerState<AccountScreen> {
@override
void dispose() {
debugPrint('[DELETE_FLOW] AccountScreen dispose');
_emailController.dispose();
_passwordController.dispose();
_displayNameController.dispose();
@ -1127,42 +1134,86 @@ class _AccountScreenState extends ConsumerState<AccountScreen> {
}
Future<void> _handleDeleteAccount(AuthUser user) async {
final confirmed = await showDialog<bool>(
debugPrint('[DELETE_FLOW] delete dialog opened');
final controller = TextEditingController();
final formKey = GlobalKey<FormState>();
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.'),
actions: [
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('CANCEL')),
FilledButton(onPressed: () => Navigator.pop(context, true), child: const Text('DELETE ACCOUNT')),
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,
),
],
],
),
) ?? false;
if (!confirmed || !mounted) return;
String? password;
if (user.providerId == 'password') {
final controller = TextEditingController();
password = await showDialog<String>(
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')),
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'),
),
],
),
);
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.',
),
),
);
}
}
}

View File

@ -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;
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;
}
}
}

View File

@ -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);

View File

@ -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<String, dynamic> &&
decoded['success'] == true &&
decoded['deletedObjects'] is int &&
(decoded['deletedObjects'] as int) >= 0;
} on FormatException {
return false;
}
}
}

View File

@ -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<AuthState>.broadcast();
String? token = 'test-token';
AuthUser? user = const AuthUser(
uid: 'cleanup-test',
email: 'cleanup@test.com',
providerId: 'password',
emailVerified: true,
);
@override
Stream<AuthState> 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<void> reauthenticate({String? password}) async {}
@override
Future<void> deleteCurrentUser() async {}
@override
Future<String?> getIdToken([bool forceRefresh = false]) async => token;
@override
Future<void> signInWithEmailAndPassword(String email, String password) async {}
@override
Future<void> createUserWithEmailAndPassword(String email, String password, {String? displayName}) async {}
@override
Future<void> signInWithGoogle() async {}
@override
Future<void> signInAnonymously() async {}
@override
Future<void> sendPasswordResetEmail(String email) async {}
@override
Future<void> sendEmailVerification() async {}
@override
Future<void> reloadCurrentUser() async {}
@override
Future<void> 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<StateError>().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<StateError>().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<StateError>().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<StateError>().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<StateError>().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<StateError>().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<StateError>().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<StateError>().having(
(error) => error.message,
'message',
'MotorCloud cleanup endpoint is unavailable.',
)),
);
});
}

View File

@ -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<AuthState>.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<AuthState> 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<void> reauthenticate({String? password}) async {
if (reauthError != null) throw reauthError!;
reauthenticated = true;
}
@override
Future<void> deleteCurrentUser() async {
deleted = true;
user = null;
_states.add(currentState);
}
@override
Future<String?> getIdToken([bool forceRefresh = false]) async => 'test-token';
@override
Future<void> signInWithEmailAndPassword(String email, String password) async {}
@override
Future<void> createUserWithEmailAndPassword(String email, String password, {String? displayName}) async {}
@override
Future<void> signInWithGoogle() async {}
@override
Future<void> signInAnonymously() async {}
@override
Future<void> sendPasswordResetEmail(String email) async {}
@override
Future<void> sendEmailVerification() async {}
@override
Future<void> reloadCurrentUser() async {}
@override
Future<void> signOut() async {}
@override
void dispose() => _states.close();
}
class _FakeCloud implements CloudAccountCleanup {
bool called = false;
Object? error;
@override
Future<void> deleteCloudData() async {
called = true;
if (error != null) throw error!;
}
}
Future<AppDatabase> _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);
});
}