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 {
|
import {
|
||||||
assertFails,
|
assertFails,
|
||||||
assertSucceeds,
|
assertSucceeds,
|
||||||
initializeTestEnvironment,
|
initializeTestEnvironment,
|
||||||
} from '@firebase/rules-unit-testing';
|
} 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 fs from 'node:fs';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
|
|
||||||
|
|
@ -24,6 +24,71 @@ afterAll(async () => {
|
||||||
await testEnv?.cleanup();
|
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', () => {
|
describe('MotorCloud ride ownership rules', () => {
|
||||||
test('a signed-in owner can create and read their own ride', async () => {
|
test('a signed-in owner can create and read their own ride', async () => {
|
||||||
const alice = testEnv.authenticatedContext('alice').firestore();
|
const alice = testEnv.authenticatedContext('alice').firestore();
|
||||||
|
|
@ -82,4 +147,42 @@ describe('MotorCloud ride ownership rules', () => {
|
||||||
|
|
||||||
await assertFails(getDoc(ride));
|
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",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "vitest run",
|
"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": {
|
"devDependencies": {
|
||||||
"@firebase/rules-unit-testing": "^5.0.2",
|
"@firebase/rules-unit-testing": "^5.0.2",
|
||||||
|
|
|
||||||
|
|
@ -50,7 +50,10 @@ service cloud.firestore {
|
||||||
match /rides/{rideId} {
|
match /rides/{rideId} {
|
||||||
allow create: if validRideCreate(userId, rideId);
|
allow create: if validRideCreate(userId, rideId);
|
||||||
allow update: if validRideUpdate(userId);
|
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} {
|
match /events/{eventId} {
|
||||||
allow read, write: if ownsUser(userId);
|
allow read, write: if ownsUser(userId);
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,12 @@ class AccountScreen extends ConsumerStatefulWidget {
|
||||||
|
|
||||||
enum _AuthFormMode { signIn, register, forgotPassword }
|
enum _AuthFormMode { signIn, register, forgotPassword }
|
||||||
|
|
||||||
|
class _DeleteAccountDialogResult {
|
||||||
|
final String? password;
|
||||||
|
|
||||||
|
const _DeleteAccountDialogResult({this.password});
|
||||||
|
}
|
||||||
|
|
||||||
class _AccountScreenState extends ConsumerState<AccountScreen> {
|
class _AccountScreenState extends ConsumerState<AccountScreen> {
|
||||||
final _formKey = GlobalKey<FormState>();
|
final _formKey = GlobalKey<FormState>();
|
||||||
final _emailController = TextEditingController();
|
final _emailController = TextEditingController();
|
||||||
|
|
@ -30,6 +36,7 @@ class _AccountScreenState extends ConsumerState<AccountScreen> {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
debugPrint('[DELETE_FLOW] AccountScreen dispose');
|
||||||
_emailController.dispose();
|
_emailController.dispose();
|
||||||
_passwordController.dispose();
|
_passwordController.dispose();
|
||||||
_displayNameController.dispose();
|
_displayNameController.dispose();
|
||||||
|
|
@ -1127,42 +1134,86 @@ class _AccountScreenState extends ConsumerState<AccountScreen> {
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _handleDeleteAccount(AuthUser user) async {
|
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,
|
context: context,
|
||||||
builder: (context) => AlertDialog(
|
builder: (dialogContext) => AlertDialog(
|
||||||
title: const Text('Delete Account'),
|
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: [
|
actions: [
|
||||||
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('CANCEL')),
|
TextButton(
|
||||||
FilledButton(onPressed: () => Navigator.pop(context, true), child: const Text('DELETE ACCOUNT')),
|
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 (!mounted || result == null) {
|
||||||
if (user.providerId == 'password') {
|
if (mounted) debugPrint('[DELETE_FLOW] delete dialog cancelled');
|
||||||
final controller = TextEditingController();
|
return;
|
||||||
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')),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
controller.dispose();
|
|
||||||
if (password == null || password.isEmpty || !mounted) return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await ref.read(accountLifecycleServiceProvider).deleteAccount(password: password);
|
await ref.read(accountLifecycleServiceProvider).deleteAccount(password: result.password);
|
||||||
if (mounted) ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Account deleted. Local rides remain on this device.')));
|
|
||||||
} catch (e) {
|
} 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 '../../database/database.dart';
|
||||||
import '../cloud/account_cloud_cleanup_service.dart';
|
import '../cloud/account_cloud_cleanup_service.dart';
|
||||||
import 'auth_service.dart';
|
import 'auth_service.dart';
|
||||||
|
|
@ -14,9 +15,45 @@ class AccountLifecycleService {
|
||||||
final user = _auth.currentUser;
|
final user = _auth.currentUser;
|
||||||
if (user == null || user.isAnonymous) throw StateError('A permanent account is required.');
|
if (user == null || user.isAnonymous) throw StateError('A permanent account is required.');
|
||||||
final uid = user.uid;
|
final uid = user.uid;
|
||||||
await _auth.reauthenticate(password: password);
|
var stage = 'reauth';
|
||||||
await _cloud.deleteCloudData();
|
try {
|
||||||
await _auth.deleteCurrentUser();
|
debugPrint('[DELETE_FLOW] reauth start');
|
||||||
await _db.clearCloudOwnerUid(uid);
|
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 'dart:async';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:firebase_core/firebase_core.dart';
|
import 'package:firebase_core/firebase_core.dart';
|
||||||
import 'package:firebase_auth/firebase_auth.dart' as fb;
|
import 'package:firebase_auth/firebase_auth.dart' as fb;
|
||||||
import 'package:google_sign_in/google_sign_in.dart';
|
import 'package:google_sign_in/google_sign_in.dart';
|
||||||
|
|
@ -87,6 +88,7 @@ class FirebaseAuthService implements AuthService {
|
||||||
_fbAuthSubscription = auth.authStateChanges().listen(
|
_fbAuthSubscription = auth.authStateChanges().listen(
|
||||||
(fb.User? user) {
|
(fb.User? user) {
|
||||||
if (user == null) {
|
if (user == null) {
|
||||||
|
debugPrint('[DELETE_FLOW] auth state callback signedOut');
|
||||||
_updateState(const AuthState.unauthenticated(isFirebaseConfigured: true));
|
_updateState(const AuthState.unauthenticated(isFirebaseConfigured: true));
|
||||||
} else {
|
} else {
|
||||||
final appUser = _mapFirebaseUser(user);
|
final appUser = _mapFirebaseUser(user);
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,8 @@
|
||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
import 'package:cloud_firestore/cloud_firestore.dart';
|
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||||
import 'package:firebase_core/firebase_core.dart';
|
import 'package:firebase_core/firebase_core.dart';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
import '../auth/auth_service.dart';
|
import '../auth/auth_service.dart';
|
||||||
import '../sync/r2_upload_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.');
|
if (user == null || user.isAnonymous) throw StateError('A permanent account is required.');
|
||||||
final token = await _auth.getIdToken(true);
|
final token = await _auth.getIdToken(true);
|
||||||
if (token == null || token.isEmpty) throw StateError('A valid account session is required.');
|
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(
|
final response = await _client.delete(
|
||||||
Uri.parse('$_workerUrl/account/cloud-data'),
|
endpoint,
|
||||||
headers: {'Authorization': 'Bearer ${token.trim()}'},
|
headers: {'Authorization': 'Bearer ${token.trim()}'},
|
||||||
).timeout(const Duration(seconds: 30));
|
).timeout(const Duration(seconds: 30));
|
||||||
if (response.statusCode < 200 || response.statusCode >= 300) {
|
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;
|
final firestore = _firestore;
|
||||||
|
|
@ -76,4 +96,25 @@ class AccountCloudCleanupService implements CloudAccountCleanup {
|
||||||
await batch.commit();
|
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