fix: harden account deletion lifecycle and cloud cleanup
This commit is contained in:
parent
cdcbfbf108
commit
885d562690
|
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"firestore": {
|
||||
"rules": "firestore.rules"
|
||||
},
|
||||
"emulators": {
|
||||
"firestore": {
|
||||
"port": 8088
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
)),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
Loading…
Reference in New Issue