feat: harden account lifecycle and cloud deletion

This commit is contained in:
Developer 2026-09-03 08:44:25 -04:00
parent 353b6fb66c
commit 8e0e2d5f97
13 changed files with 477 additions and 17 deletions

View File

@ -15,11 +15,12 @@ export interface Env {
interface FirebaseTokenPayload extends JWTPayload {
user_id?: string;
email?: string;
firebase?: { sign_in_provider?: string };
}
const CORS_HEADERS = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, PUT, OPTIONS',
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-Remote-Ride-Id, X-Bundle-Sha256, X-App-Version',
'Access-Control-Expose-Headers': 'ETag, Content-Disposition, X-MotorSignal-Ride-Id, X-MotorSignal-Sha256',
};
@ -48,6 +49,24 @@ export default {
if (!authUser?.sub) return jsonResponse({ error: 'Invalid, expired, or unauthorized Firebase ID token' }, 401);
const authenticatedUid = authUser.sub;
if (request.method === 'DELETE' && path === '/account/cloud-data') {
if (authUser.firebase?.sign_in_provider === 'anonymous') {
return jsonResponse({ error: 'Permanent account required' }, 403);
}
const prefix = `users/${authenticatedUid}/`;
let cursor: string | undefined;
let deleted = 0;
do {
const listed = await env.R2_BUCKET.list({ prefix, cursor });
if (listed.objects.length > 0) {
await env.R2_BUCKET.delete(listed.objects.map((object) => object.key));
deleted += listed.objects.length;
}
cursor = listed.truncated ? listed.cursor : undefined;
} while (cursor);
return jsonResponse({ success: true, deletedObjects: deleted });
}
if (request.method === 'PUT' && path === '/r2/upload') {
const remoteRideId = request.headers.get('X-Remote-Ride-Id');
if (!remoteRideId || !isValidRideId(remoteRideId)) return jsonResponse({ error: 'Invalid or missing X-Remote-Ride-Id header' }, 400);

View File

@ -584,6 +584,24 @@ class _AccountScreenState extends ConsumerState<AccountScreen> {
'Last Sign-In',
user.lastSignInTime!.toLocal().toString().split('.')[0],
),
if (user.providerId == 'password' && !user.emailVerified) ...[
const SizedBox(height: 12),
Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: Colors.amberAccent.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.amberAccent.withValues(alpha: 0.5)),
),
child: Row(
children: [
const Expanded(child: Text('Your email is not verified. Verify it to protect account recovery.', style: TextStyle(fontSize: 11, color: Colors.white))),
TextButton(onPressed: () => _sendVerification(), child: const Text('VERIFY')),
IconButton(tooltip: 'Refresh verification status', onPressed: () => _reloadUser(), icon: const Icon(Icons.refresh, size: 18)),
],
),
),
],
const SizedBox(height: 24),
SizedBox(
width: double.infinity,
@ -608,6 +626,16 @@ class _AccountScreenState extends ConsumerState<AccountScreen> {
ref.read(authNotifierProvider.notifier).signOut(),
),
),
const SizedBox(height: 10),
SizedBox(
width: double.infinity,
child: TextButton.icon(
style: TextButton.styleFrom(foregroundColor: Colors.redAccent),
icon: const Icon(Icons.delete_forever_outlined, size: 18),
label: const Text('DELETE ACCOUNT'),
onPressed: () => _handleDeleteAccount(user),
),
),
],
),
),
@ -1081,6 +1109,63 @@ class _AccountScreenState extends ConsumerState<AccountScreen> {
);
}
Future<void> _sendVerification() async {
try {
await ref.read(authNotifierProvider.notifier).sendEmailVerification();
if (mounted) ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Verification email sent.')));
} catch (e) {
if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Could not send verification email: $e')));
}
}
Future<void> _reloadUser() async {
try {
await ref.read(authNotifierProvider.notifier).reloadCurrentUser();
} catch (e) {
if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Could not refresh account status: $e')));
}
}
Future<void> _handleDeleteAccount(AuthUser user) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Delete Account'),
content: const Text('This permanently deletes your MotorCloud backups and account. Local rides and garage data stay on this device. You will need to reauthenticate before deletion.'),
actions: [
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('CANCEL')),
FilledButton(onPressed: () => Navigator.pop(context, true), child: const Text('DELETE ACCOUNT')),
],
),
) ?? false;
if (!confirmed || !mounted) return;
String? password;
if (user.providerId == 'password') {
final controller = TextEditingController();
password = await showDialog<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 {
await ref.read(accountLifecycleServiceProvider).deleteAccount(password: password);
if (mounted) ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Account deleted. Local rides remain on this device.')));
} catch (e) {
if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Account deletion was not completed: $e')));
}
}
Future<void> _handleBackupAllHistoricalRides() async {
final db = ref.read(databaseProvider);
final syncManager = ref.read(syncManagerProvider);

View File

@ -0,0 +1,22 @@
import '../../database/database.dart';
import '../cloud/account_cloud_cleanup_service.dart';
import 'auth_service.dart';
/// Coordinates destructive account work while preserving device-local rides.
class AccountLifecycleService {
final AuthService _auth;
final CloudAccountCleanup _cloud;
final AppDatabase _db;
AccountLifecycleService({required this._auth, required this._cloud, required this._db});
Future<void> deleteAccount({String? password}) async {
final user = _auth.currentUser;
if (user == null || user.isAnonymous) throw StateError('A permanent account is required.');
final uid = user.uid;
await _auth.reauthenticate(password: password);
await _cloud.deleteCloudData();
await _auth.deleteCurrentUser();
await _db.clearCloudOwnerUid(uid);
}
}

View File

@ -56,6 +56,24 @@ class AuthNotifier extends StateNotifier<AuthState> {
state = _authService.currentState;
}
Future<void> sendEmailVerification() async {
await _authService.sendEmailVerification();
state = _authService.currentState;
}
Future<void> reloadCurrentUser() async {
await _authService.reloadCurrentUser();
state = _authService.currentState;
}
Future<void> reauthenticate({String? password}) =>
_authService.reauthenticate(password: password);
Future<void> deleteCurrentUser() async {
await _authService.deleteCurrentUser();
state = _authService.currentState;
}
Future<void> signOut() async {
await _authService.signOut();
state = _authService.currentState;

View File

@ -13,12 +13,17 @@ abstract class AuthService {
AuthState get currentState;
AuthUser? get currentUser;
bool get isConfigured;
bool get isEmailVerified => false;
Future<void> signInWithEmailAndPassword(String email, String password);
Future<void> createUserWithEmailAndPassword(String email, String password, {String? displayName});
Future<void> signInWithGoogle();
Future<void> signInAnonymously();
Future<void> sendPasswordResetEmail(String email);
Future<void> sendEmailVerification() async => throw UnsupportedError('Email verification is not supported by this auth service.');
Future<void> reloadCurrentUser() async => throw UnsupportedError('User reload is not supported by this auth service.');
Future<void> reauthenticate({String? password}) async => throw UnsupportedError('Reauthentication is not supported by this auth service.');
Future<void> deleteCurrentUser() async => throw UnsupportedError('Account deletion is not supported by this auth service.');
Future<String?> getIdToken([bool forceRefresh = false]);
Future<void> signOut();
void dispose();
@ -125,6 +130,7 @@ class FirebaseAuthService implements AuthService {
displayName: user.displayName,
photoUrl: user.photoURL,
isAnonymous: user.isAnonymous,
emailVerified: user.emailVerified,
providerId: provider,
createdAt: user.metadata.creationTime,
lastSignInTime: user.metadata.lastSignInTime,
@ -367,6 +373,58 @@ class FirebaseAuthService implements AuthService {
}
}
@override
bool get isEmailVerified => _firebaseAuth?.currentUser?.emailVerified ?? false;
@override
Future<void> sendEmailVerification() async {
final user = _firebaseAuth?.currentUser;
if (user == null || user.isAnonymous) throw StateError('A permanent account is required.');
await user.sendEmailVerification();
}
@override
Future<void> reloadCurrentUser() async {
final user = _firebaseAuth?.currentUser;
if (user == null) throw StateError('No signed-in account.');
await user.reload();
final refreshed = _firebaseAuth?.currentUser;
if (refreshed != null) {
refreshed.isAnonymous
? _updateState(AuthState.anonymous(_mapFirebaseUser(refreshed), isFirebaseConfigured: true))
: _updateAuthenticatedUser(refreshed);
}
}
@override
Future<void> reauthenticate({String? password}) async {
final auth = _firebaseAuth;
final user = auth?.currentUser;
if (auth == null || user == null || user.isAnonymous) throw StateError('A permanent account is required.');
final providers = user.providerData.map((p) => p.providerId).toSet();
fb.AuthCredential credential;
if (providers.contains('password')) {
if (password == null || password.isEmpty || user.email == null) throw StateError('Enter your current password.');
credential = fb.EmailAuthProvider.credential(email: user.email!, password: password);
} else if (providers.contains('google.com')) {
final googleUser = await (_googleSignIn ?? GoogleSignIn()).signIn();
if (googleUser == null) throw StateError('Google reauthentication was cancelled.');
final googleAuth = await googleUser.authentication;
credential = fb.GoogleAuthProvider.credential(accessToken: googleAuth.accessToken, idToken: googleAuth.idToken);
} else {
throw StateError('This account provider cannot reauthenticate here.');
}
await user.reauthenticateWithCredential(credential);
}
@override
Future<void> deleteCurrentUser() async {
final user = _firebaseAuth?.currentUser;
if (user == null || user.isAnonymous) throw StateError('A permanent account is required.');
await user.delete();
_updateState(const AuthState.unauthenticated(isFirebaseConfigured: true));
}
@override
Future<String?> getIdToken([bool forceRefresh = false]) async {
final auth = _firebaseAuth;

View File

@ -9,6 +9,7 @@ class AuthUser {
final String? displayName;
final String? photoUrl;
final bool isAnonymous;
final bool emailVerified;
final String? providerId;
final DateTime? createdAt;
final DateTime? lastSignInTime;
@ -19,6 +20,7 @@ class AuthUser {
this.displayName,
this.photoUrl,
this.isAnonymous = false,
this.emailVerified = false,
this.providerId,
this.createdAt,
this.lastSignInTime,
@ -41,6 +43,7 @@ class AuthUser {
'displayName': displayName,
'photoUrl': photoUrl,
'isAnonymous': isAnonymous,
'emailVerified': emailVerified,
'providerId': providerId,
'createdAt': createdAt?.toIso8601String(),
'lastSignInTime': lastSignInTime?.toIso8601String(),
@ -52,6 +55,7 @@ class AuthUser {
displayName: json['displayName'] as String?,
photoUrl: json['photoUrl'] as String?,
isAnonymous: json['isAnonymous'] as bool? ?? false,
emailVerified: json['emailVerified'] as bool? ?? false,
providerId: json['providerId'] as String?,
createdAt: json['createdAt'] != null
? DateTime.tryParse(json['createdAt'] as String)
@ -67,6 +71,7 @@ class AuthUser {
String? displayName,
String? photoUrl,
bool? isAnonymous,
bool? emailVerified,
String? providerId,
DateTime? createdAt,
DateTime? lastSignInTime,
@ -77,6 +82,7 @@ class AuthUser {
displayName: displayName ?? this.displayName,
photoUrl: photoUrl ?? this.photoUrl,
isAnonymous: isAnonymous ?? this.isAnonymous,
emailVerified: emailVerified ?? this.emailVerified,
providerId: providerId ?? this.providerId,
createdAt: createdAt ?? this.createdAt,
lastSignInTime: lastSignInTime ?? this.lastSignInTime,
@ -92,6 +98,7 @@ class AuthUser {
email == other.email &&
displayName == other.displayName &&
isAnonymous == other.isAnonymous &&
emailVerified == other.emailVerified &&
providerId == other.providerId;
@override
@ -100,6 +107,7 @@ class AuthUser {
email.hashCode ^
displayName.hashCode ^
isAnonymous.hashCode ^
emailVerified.hashCode ^
providerId.hashCode;
@override

View File

@ -0,0 +1,63 @@
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:firebase_core/firebase_core.dart';
import 'package:http/http.dart' as http;
import '../auth/auth_service.dart';
import '../sync/r2_upload_service.dart';
abstract class CloudAccountCleanup {
Future<void> deleteCloudData();
}
class AccountCloudCleanupService implements CloudAccountCleanup {
final AuthService _auth;
final FirebaseFirestore? _firestore;
final http.Client _client;
final String _workerUrl;
AccountCloudCleanupService({
required this._auth,
FirebaseFirestore? firestore,
http.Client? client,
String? workerUrl,
}) : _firestore = firestore ?? _resolveFirestore(),
_client = client ?? http.Client(),
_workerUrl = (workerUrl ?? R2UploadService.defaultWorkerUrl).replaceAll(RegExp(r'/+$'), '');
static FirebaseFirestore? _resolveFirestore() {
try {
if (Firebase.apps.isNotEmpty) return FirebaseFirestore.instance;
} catch (_) {}
return null;
}
@override
Future<void> deleteCloudData() async {
final user = _auth.currentUser;
if (user == null || user.isAnonymous) throw StateError('A permanent account is required.');
final token = await _auth.getIdToken(true);
if (token == null || token.isEmpty) throw StateError('A valid account session is required.');
final response = await _client.delete(
Uri.parse('$_workerUrl/account/cloud-data'),
headers: {'Authorization': 'Bearer ${token.trim()}'},
).timeout(const Duration(seconds: 30));
if (response.statusCode < 200 || response.statusCode >= 300) {
throw StateError('MotorCloud cleanup was not completed (${response.statusCode}).');
}
final firestore = _firestore;
if (firestore == null) throw StateError('MotorCloud database is unavailable.');
final userRef = firestore.collection('users').doc(user.uid);
final rides = await userRef.collection('rides').get();
final motorcycles = await userRef.collection('motorcycles').get();
final batch = firestore.batch();
for (final doc in rides.docs) {
batch.delete(doc.reference);
}
for (final doc in motorcycles.docs) {
batch.delete(doc.reference);
}
batch.delete(userRef);
await batch.commit();
}
}

View File

@ -2,6 +2,7 @@ import 'dart:convert';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:http/http.dart' as http;
import 'package:shared_preferences/shared_preferences.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import '../../features/records/records_service.dart';
/// Secure credentials and repository configuration for GitHub ride sync
@ -15,29 +16,64 @@ class GitHubAuthService {
final SharedPreferences _prefs;
final http.Client _client;
final FlutterSecureStorage _secureStorage;
String? _token;
GitHubAuthService(this._prefs, [http.Client? client]) : _client = client ?? http.Client();
GitHubAuthService(this._prefs, [http.Client? client, FlutterSecureStorage? secureStorage])
: _client = client ?? http.Client(),
_secureStorage = secureStorage ?? const FlutterSecureStorage() {
_migrateToken();
}
Future<void> _migrateToken() async {
try {
final secure = await _secureStorage.read(key: _keyToken);
if (secure != null && secure.isNotEmpty) {
_token = secure;
return;
}
final legacy = _prefs.getString(_keyToken);
if (legacy == null || legacy.isEmpty) return;
final decoded = _decodeLegacy(legacy);
await _secureStorage.write(key: _keyToken, value: decoded);
await _prefs.remove(_keyToken);
_token = decoded;
} catch (_) {}
}
String _decodeLegacy(String raw) {
try { return utf8.decode(base64.decode(raw)); } catch (_) { return raw; }
}
/// Reads stored Personal Access Token
String? getToken() {
final raw = _prefs.getString(_keyToken);
if (raw == null || raw.isEmpty) return null;
try {
return utf8.decode(base64.decode(raw));
} catch (_) {
return raw;
}
if (_token != null && _token!.isNotEmpty) return _token;
final legacy = _prefs.getString(_keyToken);
if (legacy == null || legacy.isEmpty) return null;
// Legacy values are read only to bridge an upgrade; constructor migration
// moves them to secure storage and removes the old entry.
return _decodeLegacy(legacy);
}
/// Stores Personal Access Token securely
Future<void> saveToken(String token) async {
final encoded = base64.encode(utf8.encode(token.trim()));
await _prefs.setString(_keyToken, encoded);
_token = token.trim();
try {
await _secureStorage.write(key: _keyToken, value: _token);
await _prefs.remove(_keyToken);
} catch (_) {
// Native secure storage is unavailable in plugin-less test environments;
// keep the token only in memory rather than persist it insecurely.
}
}
/// Clears stored token
Future<void> clearToken() async {
await _prefs.remove(_keyToken);
try {
await _secureStorage.delete(key: _keyToken);
} catch (_) {}
_token = null;
}
/// Target GitHub repository (default: bponders12-cell/ride_logs)

View File

@ -5,6 +5,7 @@ import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:http/http.dart' as http;
import 'package:shared_preferences/shared_preferences.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:url_launcher/url_launcher.dart';
class OsmUserProfile {
@ -79,6 +80,8 @@ class OsmOAuthService extends StateNotifier<OsmAuthState> {
static const String _prefTokenKey = 'osm_access_token';
static const String _prefUserKey = 'osm_user_profile';
static const String _prefVerifierKey = 'osm_pkce_code_verifier';
static const String _secureTokenKey = 'osm_access_token';
final FlutterSecureStorage _secureStorage = const FlutterSecureStorage();
String? _currentCodeVerifier;
String? _inFlightCode;
@ -92,7 +95,14 @@ class OsmOAuthService extends StateNotifier<OsmAuthState> {
Future<void> _loadStoredSession() async {
try {
final prefs = await SharedPreferences.getInstance();
final token = prefs.getString(_prefTokenKey);
var token = await _secureStorage.read(key: _secureTokenKey);
if (token == null || token.isEmpty) {
token = prefs.getString(_prefTokenKey);
if (token != null && token.isNotEmpty) {
await _secureStorage.write(key: _secureTokenKey, value: token);
await prefs.remove(_prefTokenKey);
}
}
final userJsonStr = prefs.getString(_prefUserKey);
if (token != null && token.isNotEmpty) {
@ -225,7 +235,7 @@ class OsmOAuthService extends StateNotifier<OsmAuthState> {
return false;
}
debugPrint('[OsmOAuth] Exchanging token with code: $code, redirect: $redirectUri');
debugPrint('[OsmOAuth] Exchanging authorization code');
final response = await http.post(
Uri.parse(tokenEndpoint),
headers: {
@ -240,13 +250,14 @@ class OsmOAuthService extends StateNotifier<OsmAuthState> {
},
);
debugPrint('[OsmOAuth] Token response (${response.statusCode}): ${response.body}');
debugPrint('[OsmOAuth] Token exchange response: ${response.statusCode}');
if (response.statusCode == 200) {
final json = jsonDecode(response.body) as Map<String, dynamic>;
final accessToken = json['access_token'] as String;
await prefs.setString(_prefTokenKey, accessToken);
await _secureStorage.write(key: _secureTokenKey, value: accessToken);
await prefs.remove(_prefTokenKey);
await prefs.remove(_prefVerifierKey);
_currentCodeVerifier = null;
@ -261,7 +272,7 @@ class OsmOAuthService extends StateNotifier<OsmAuthState> {
} else {
state = state.copyWith(
isLoading: false,
errorMessage: 'Token exchange failed (${response.statusCode}): ${response.body}',
errorMessage: 'Token exchange failed (${response.statusCode}).',
);
return false;
}
@ -343,6 +354,7 @@ class OsmOAuthService extends StateNotifier<OsmAuthState> {
Future<void> logout() async {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_prefTokenKey);
await _secureStorage.delete(key: _secureTokenKey);
await prefs.remove(_prefUserKey);
await prefs.remove(_prefVerifierKey);
state = const OsmAuthState(isAuthenticated: false, isLoading: false);

View File

@ -4,6 +4,8 @@ import '../../features/records/records_service.dart';
import '../auth/auth_providers.dart';
import '../auth/auth_state.dart';
import '../cloud/firebase_account_sync_service.dart';
import '../cloud/account_cloud_cleanup_service.dart';
import '../auth/account_lifecycle_service.dart';
import '../github/github_auth_service.dart';
import '../github/github_sync_service.dart';
import 'firebase_sync_provider.dart';
@ -64,6 +66,18 @@ final firebaseAccountSyncServiceProvider = Provider<FirebaseAccountSyncService>(
),
);
final accountCloudCleanupServiceProvider = Provider<AccountCloudCleanupService>(
(ref) => AccountCloudCleanupService(auth: ref.watch(authServiceProvider)),
);
final accountLifecycleServiceProvider = Provider<AccountLifecycleService>(
(ref) => AccountLifecycleService(
auth: ref.watch(authServiceProvider),
cloud: ref.watch(accountCloudCleanupServiceProvider),
db: ref.watch(databaseProvider),
),
);
final githubSyncProvider = Provider<GitHubSyncProvider>(
(ref) => GitHubSyncProvider(
service: ref.watch(githubSyncServiceProvider),

View File

@ -467,6 +467,54 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.6.1"
flutter_secure_storage:
dependency: "direct main"
description:
name: flutter_secure_storage
sha256: "7686b1d6a29985dcbb808c59518226e603e3bfa7c0ddfd1a0d00e4cda77c868e"
url: "https://pub.dev"
source: hosted
version: "10.3.1"
flutter_secure_storage_darwin:
dependency: transitive
description:
name: flutter_secure_storage_darwin
sha256: "82329fa5cdf343773b1b6897dea959105a29f092454259edff92f9f6637e8149"
url: "https://pub.dev"
source: hosted
version: "0.3.2"
flutter_secure_storage_linux:
dependency: transitive
description:
name: flutter_secure_storage_linux
sha256: "76fa9c841b3b1619fc5b5bc36efc7d158fa2356f223b6caeb1d0c80a54168546"
url: "https://pub.dev"
source: hosted
version: "3.0.2"
flutter_secure_storage_platform_interface:
dependency: transitive
description:
name: flutter_secure_storage_platform_interface
sha256: "788060052712555182aba55ecb5f8b6e5cb9cfe8f776c83249a61fe3ce877db4"
url: "https://pub.dev"
source: hosted
version: "2.0.3"
flutter_secure_storage_web:
dependency: transitive
description:
name: flutter_secure_storage_web
sha256: "073a62b3aeb866ab4ce795f960413948e51e5a42a9b0c8333b6daf5bb3208a1c"
url: "https://pub.dev"
source: hosted
version: "2.1.1"
flutter_secure_storage_windows:
dependency: transitive
description:
name: flutter_secure_storage_windows
sha256: "471951813a97006d899db4948acc654a4f28c440083ea08178935ce20b173ec1"
url: "https://pub.dev"
source: hosted
version: "4.2.2"
flutter_test:
dependency: "direct dev"
description: flutter

View File

@ -63,6 +63,7 @@ dependencies:
firebase_auth: ^5.5.1
google_sign_in: ^6.2.2
cloud_firestore: ^5.6.5
flutter_secure_storage: ^10.2.1
dev_dependencies:
flutter_test:

View File

@ -0,0 +1,76 @@
import 'package:drift/native.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:motorcycle_dash/database/database.dart';
import 'package:motorcycle_dash/services/auth/account_lifecycle_service.dart';
import 'package:motorcycle_dash/services/auth/auth_service.dart';
import 'package:motorcycle_dash/services/auth/auth_state.dart';
import 'package:motorcycle_dash/services/auth/auth_user.dart';
import 'package:motorcycle_dash/services/cloud/account_cloud_cleanup_service.dart';
class _Auth implements AuthService {
AuthUser? user = const AuthUser(uid: 'user_a', email: 'a@test.com');
bool reauthenticated = false;
bool deleted = false;
@override bool get isConfigured => true;
@override bool get isEmailVerified => true;
@override AuthUser? get currentUser => user;
@override AuthState get currentState => AuthState.authenticated(user!);
@override Stream<AuthState> get authStateChanges => Stream.value(currentState);
@override Future<void> createUserWithEmailAndPassword(String e, String p, {String? displayName}) async {}
@override Future<void> sendPasswordResetEmail(String email) async {}
@override Future<void> sendEmailVerification() async {}
@override Future<void> reloadCurrentUser() async {}
@override Future<void> signInAnonymously() async {}
@override Future<void> signInWithEmailAndPassword(String e, String p) async {}
@override Future<void> signInWithGoogle() async {}
@override Future<String?> getIdToken([bool forceRefresh = false]) async => 'token';
@override Future<void> reauthenticate({String? password}) async { reauthenticated = true; }
@override Future<void> deleteCurrentUser() async { deleted = true; user = null; }
@override Future<void> signOut() async {}
@override void dispose() {}
}
class _Cloud implements CloudAccountCleanup {
bool fail = false;
bool called = false;
@override Future<void> deleteCloudData() async { called = true; if (fail) throw StateError('network'); }
}
void main() {
test('successful deletion preserves rides and clears only deleted owner', () async {
final db = AppDatabase.forTesting(NativeDatabase.memory());
addTearDown(db.close);
final owned = await db.startRide();
final other = await db.startRide();
await db.claimRideForCloudOwner(owned, 'user_a');
await db.claimRideForCloudOwner(other, 'user_b');
final auth = _Auth();
final cloud = _Cloud();
await AccountLifecycleService(auth: auth, cloud: cloud, db: db).deleteAccount(password: 'secret');
expect(auth.reauthenticated, isTrue);
expect(cloud.called, isTrue);
expect(auth.deleted, isTrue);
expect(await db.getCloudOwnerUid(owned), isNull);
expect(await db.getCloudOwnerUid(other), 'user_b');
expect(await db.getCloudRideId(owned), isNotNull);
expect((await db.select(db.rides).get()).length, 2);
});
test('cloud failure does not delete auth or clear ownership', () async {
final db = AppDatabase.forTesting(NativeDatabase.memory());
addTearDown(db.close);
final ride = await db.startRide();
await db.claimRideForCloudOwner(ride, 'user_a');
final auth = _Auth();
final cloud = _Cloud()..fail = true;
await expectLater(
AccountLifecycleService(auth: auth, cloud: cloud, db: db).deleteAccount(),
throwsStateError,
);
expect(auth.deleted, isFalse);
expect(await db.getCloudOwnerUid(ride), 'user_a');
});
}