diff --git a/lib/services/sync/firebase_sync_provider.dart b/lib/services/sync/firebase_sync_provider.dart index 3a5d3d6..bacfbfa 100644 --- a/lib/services/sync/firebase_sync_provider.dart +++ b/lib/services/sync/firebase_sync_provider.dart @@ -16,14 +16,21 @@ class FirebaseSyncProvider implements SyncProvider { final R2UploadService _r2UploadService; final SyncBundleWorker _bundleWorker; - FirebaseSyncProvider({required AppDatabase db, required this._authService, FirebaseFirestore? firestore, R2UploadService? r2UploadService, SyncBundleWorker? bundleWorker}) - : _db = db, - _firestore = firestore ?? _resolveFirestore(), - _r2UploadService = r2UploadService ?? R2UploadService(), - _bundleWorker = bundleWorker ?? SyncBundleWorker(db); + FirebaseSyncProvider({ + required AppDatabase db, + required this._authService, + FirebaseFirestore? firestore, + R2UploadService? r2UploadService, + SyncBundleWorker? bundleWorker, + }) : _db = db, + _firestore = firestore ?? _resolveFirestore(), + _r2UploadService = r2UploadService ?? R2UploadService(), + _bundleWorker = bundleWorker ?? SyncBundleWorker(db); static FirebaseFirestore? _resolveFirestore() { - try { if (Firebase.apps.isNotEmpty) return FirebaseFirestore.instance; } catch (_) {} + try { + if (Firebase.apps.isNotEmpty) return FirebaseFirestore.instance; + } catch (_) {} return null; } @@ -32,7 +39,7 @@ class FirebaseSyncProvider implements SyncProvider { @override Future isAvailable() async { - try { return Firebase.apps.isNotEmpty && _firestore != null; } catch (_) { return false; } + return _firestore != null; } @override @@ -41,12 +48,263 @@ class FirebaseSyncProvider implements SyncProvider { return user == null || user.isAnonymous; } + @override + Future isRideConfirmedInCloud(String remoteRideId) async { + final result = await verifyCloudConfirmation(remoteRideId); + return result.isConfirmed || result.isLegacyUnverified; + } + + @override + Future verifyCloudConfirmation( + String remoteRideId, + ) async { + final user = _authService.currentUser; + if (user == null || user.isAnonymous) { + return CloudConfirmationResult.unauthorized( + remoteRideId: remoteRideId, + message: + 'Sign in to MotorCloud is required to verify cloud ride status.', + ); + } + if (!await isAvailable()) { + return CloudConfirmationResult.temporarilyUnverifiable( + remoteRideId: remoteRideId, + message: 'Firebase services are unavailable or unconfigured.', + ); + } + + // Step 1: Firestore document metadata verification + DocumentSnapshot> doc; + try { + doc = await _firestore! + .collection('users') + .doc(user.uid) + .collection('rides') + .doc(remoteRideId) + .get(); + } on FirebaseException catch (e) { + if (e.code == 'permission-denied' || e.code == 'unauthenticated') { + return CloudConfirmationResult.unauthorized( + remoteRideId: remoteRideId, + ownerUid: user.uid, + message: 'Firestore access unauthorized (${e.code}): ${e.message}', + ); + } + return CloudConfirmationResult.temporarilyUnverifiable( + remoteRideId: remoteRideId, + message: 'Firestore temporary lookup error (${e.code}): ${e.message}', + ); + } catch (e) { + return CloudConfirmationResult.temporarilyUnverifiable( + remoteRideId: remoteRideId, + message: 'Firestore lookup exception: $e', + ); + } + + if (!doc.exists) { + return CloudConfirmationResult.missing( + remoteRideId: remoteRideId, + message: 'Firestore ride document not found.', + ); + } + + final data = doc.data(); + if (data == null) { + return CloudConfirmationResult.missing( + remoteRideId: remoteRideId, + message: 'Firestore ride document contains no data.', + ); + } + + // Verify ownership in metadata + final ownerId = (data['ownerId'] ?? data['uid'])?.toString(); + if (ownerId != null && ownerId != user.uid) { + return CloudConfirmationResult.unauthorized( + remoteRideId: remoteRideId, + ownerUid: ownerId, + message: + 'Firestore ride owner ($ownerId) does not match authenticated user (${user.uid}).', + ); + } + + // Verify remote ID in metadata + final storedId = (data['id'] ?? data['remoteRideId'])?.toString(); + if (storedId != null && storedId != remoteRideId) { + return CloudConfirmationResult.inconsistent( + remoteRideId: remoteRideId, + ownerUid: user.uid, + message: + 'Firestore ride id ($storedId) does not match requested ($remoteRideId).', + ); + } + + final bundle = data['telemetryBundle']; + if (bundle is! Map) { + return CloudConfirmationResult.missing( + remoteRideId: remoteRideId, + message: 'Firestore document missing telemetryBundle metadata.', + ); + } + + final bundleStatus = bundle['status']?.toString(); + if (bundleStatus != 'uploaded') { + return CloudConfirmationResult.missing( + remoteRideId: remoteRideId, + message: + 'Firestore telemetryBundle status is "$bundleStatus", not "uploaded".', + ); + } + + final expectedObjectKey = + 'users/${user.uid}/rides/$remoteRideId/telemetry.zip'; + final objectKey = (bundle['objectKey'] ?? data['telemetryBundlePath']) + ?.toString(); + if (objectKey == null || objectKey != expectedObjectKey) { + return CloudConfirmationResult.inconsistent( + remoteRideId: remoteRideId, + ownerUid: user.uid, + objectKey: objectKey, + message: + 'telemetryBundle objectKey ($objectKey) does not match expected ($expectedObjectKey).', + ); + } + + final syncVersion = (data['syncVersion'] as num?)?.toInt() ?? 1; + final expectedSha256 = (bundle['sha256'] ?? data['sha256'])?.toString(); + final expectedSize = + (bundle['bundleSizeBytes'] ?? data['bundleSizeBytes']) as num?; + + // For modern bundles (syncVersion >= 3), require positive size and non-empty sha256 + if (syncVersion >= 3) { + if (expectedSha256 == null || expectedSha256.trim().isEmpty) { + return CloudConfirmationResult.inconsistent( + remoteRideId: remoteRideId, + ownerUid: user.uid, + objectKey: expectedObjectKey, + message: + 'Modern bundle (syncVersion >= 3) missing required sha256 metadata.', + ); + } + if (expectedSize == null || expectedSize <= 0) { + return CloudConfirmationResult.inconsistent( + remoteRideId: remoteRideId, + ownerUid: user.uid, + objectKey: expectedObjectKey, + message: + 'Modern bundle (syncVersion >= 3) has invalid bundleSizeBytes ($expectedSize).', + ); + } + } + + // Step 2: R2 bundle verification via authenticated lightweight Worker check + var idToken = await _authService.getIdToken(); + if (idToken == null || idToken.isEmpty) { + idToken = await _authService.getIdToken(true); + } + if (idToken == null || idToken.isEmpty) { + return CloudConfirmationResult.unauthorized( + remoteRideId: remoteRideId, + ownerUid: user.uid, + message: 'Valid Firebase ID token required to verify R2 bundle.', + ); + } + + var r2Result = await _r2UploadService.verifyTelemetryBundle( + idToken: idToken, + remoteRideId: remoteRideId, + expectedSha256: expectedSha256, + expectedSize: expectedSize?.toInt(), + ); + + // If verification is rejected as unauthorized (e.g. expired cached token), + // force refresh the ID token once and retry before concluding unauthorized. + if (r2Result.status == CloudConfirmationStatus.unauthorized) { + final refreshedToken = await _authService.getIdToken(true); + if (refreshedToken != null && + refreshedToken.isNotEmpty && + refreshedToken != idToken) { + idToken = refreshedToken; + r2Result = await _r2UploadService.verifyTelemetryBundle( + idToken: idToken, + remoteRideId: remoteRideId, + expectedSha256: expectedSha256, + expectedSize: expectedSize?.toInt(), + ); + } + } + + switch (r2Result.status) { + case CloudConfirmationStatus.confirmed: + return CloudConfirmationResult.confirmed( + remoteRideId: remoteRideId, + ownerUid: user.uid, + objectKey: r2Result.metadata?.objectKey ?? expectedObjectKey, + sizeBytes: r2Result.metadata?.size ?? expectedSize?.toInt(), + sha256: r2Result.metadata?.sha256 ?? expectedSha256, + message: 'Firestore metadata and R2 bundle verified.', + ); + + case CloudConfirmationStatus.legacyUnverified: + return CloudConfirmationResult.legacyUnverified( + remoteRideId: remoteRideId, + ownerUid: user.uid, + objectKey: r2Result.metadata?.objectKey ?? expectedObjectKey, + sizeBytes: r2Result.metadata?.size ?? expectedSize?.toInt(), + sha256: r2Result.metadata?.sha256 ?? expectedSha256, + message: + r2Result.errorMessage ?? + 'Historical bundle verified in R2 without full checksum metadata.', + ); + + case CloudConfirmationStatus.missing: + return CloudConfirmationResult.missing( + remoteRideId: remoteRideId, + message: + r2Result.errorMessage ?? + 'Telemetry bundle missing from Cloudflare R2.', + ); + + case CloudConfirmationStatus.inconsistent: + return CloudConfirmationResult.inconsistent( + remoteRideId: remoteRideId, + ownerUid: user.uid, + objectKey: expectedObjectKey, + message: + r2Result.errorMessage ?? + 'R2 telemetry bundle checksum or metadata inconsistent.', + ); + + case CloudConfirmationStatus.unauthorized: + return CloudConfirmationResult.unauthorized( + remoteRideId: remoteRideId, + ownerUid: user.uid, + message: + r2Result.errorMessage ?? + 'Unauthorized to access R2 telemetry bundle.', + ); + + case CloudConfirmationStatus.temporarilyUnverifiable: + return CloudConfirmationResult.temporarilyUnverifiable( + remoteRideId: remoteRideId, + message: + r2Result.errorMessage ?? + 'R2 verification temporarily unavailable.', + ); + } + } + @override Future uploadRide(SyncItem item) async { final user = _authService.currentUser; - if (user == null || user.isAnonymous) return SyncResult.authRequired(message: 'Sign in to MotorCloud is required for cloud backup.'); + if (user == null || user.isAnonymous) { + return SyncResult.authRequired( + message: 'Sign in to MotorCloud is required for cloud backup.', + ); + } - final ride = await (_db.select(_db.rides)..where((r) => r.id.equals(item.localRideId))).getSingleOrNull(); + final ride = await (_db.select( + _db.rides, + )..where((r) => r.id.equals(item.localRideId))).getSingleOrNull(); if (ride != null && ride.cloudOwnerUid != user.uid) { return SyncResult.permanentFailure( ride.cloudOwnerUid == null @@ -54,7 +312,16 @@ class FirebaseSyncProvider implements SyncProvider { : 'This ride belongs to another MotorCloud account and was not uploaded.', ); } - if (!await isAvailable()) return SyncResult.temporaryFailure('Firebase services are unavailable or unconfigured. Ride remains safely stored locally.'); + if (!await isAvailable()) { + return SyncResult.temporaryFailure( + 'Firebase services are unavailable or unconfigured. Ride remains safely stored locally.', + ); + } + if (ride == null) { + return SyncResult.permanentFailure( + 'Local ride #${item.localRideId} not found in database.', + ); + } // Refresh the Firebase ID token before a manual upload. This prevents a // recently completed sign-in from being rejected because the cached token @@ -63,18 +330,46 @@ class FirebaseSyncProvider implements SyncProvider { if (idToken == null || idToken.isEmpty) { idToken = await _authService.getIdToken(true); } - if (idToken == null || idToken.isEmpty) return SyncResult.authRequired(message: 'Valid Firebase authentication token required for cloud sync.'); + if (idToken == null || idToken.isEmpty) { + return SyncResult.authRequired( + message: 'Valid Firebase authentication token required for cloud sync.', + ); + } try { - final ride = await (_db.select(_db.rides)..where((r) => r.id.equals(item.localRideId))).getSingleOrNull(); - if (ride == null) return SyncResult.permanentFailure('Local ride #${item.localRideId} not found in database.'); - // The cloud identity is generated once and then reused forever. Never // derive the remote identity from the local auto-increment database ID. final remoteRideId = await _db.ensureCloudRideId(item.localRideId); + final confirmation = await verifyCloudConfirmation(remoteRideId); + if (confirmation.isConfirmed || confirmation.isLegacyUnverified) { + return SyncResult.success(remoteId: remoteRideId); + } + if (confirmation.isInconsistent) { + return SyncResult.permanentFailure( + 'Cloud data inconsistent for ride $remoteRideId: ${confirmation.message}. Re-upload aborted to prevent overwriting cloud object.', + ); + } + if (confirmation.isUnauthorized) { + return SyncResult.authRequired( + message: + 'Cloud verification unauthorized for ride $remoteRideId: ${confirmation.message}', + ); + } + if (confirmation.isTemporarilyUnverifiable) { + return SyncResult.temporaryFailure( + 'Cloud confirmation temporarily unavailable for ride $remoteRideId: ${confirmation.message}', + ); + } + final appIdentity = AppIdentity.current(); - final zipBytes = await _bundleWorker.generateCompressedBundle(item.localRideId); - if (zipBytes == null || zipBytes.isEmpty) return SyncResult.permanentFailure('Failed generating compressed telemetry bundle for ride #${item.localRideId}.'); + final zipBytes = await _bundleWorker.generateCompressedBundle( + item.localRideId, + ); + if (zipBytes == null || zipBytes.isEmpty) { + return SyncResult.permanentFailure( + 'Failed generating compressed telemetry bundle for ride #${item.localRideId}.', + ); + } final sha256Hash = sha256.convert(zipBytes).toString(); var r2Result = await _r2UploadService.uploadTelemetryBundle( @@ -90,7 +385,9 @@ class FirebaseSyncProvider implements SyncProvider { // turning a valid signed-in session into a false authentication error. if (!r2Result.success && r2Result.requiresAuth) { final refreshedToken = await _authService.getIdToken(true); - if (refreshedToken != null && refreshedToken.isNotEmpty && refreshedToken != idToken) { + if (refreshedToken != null && + refreshedToken.isNotEmpty && + refreshedToken != idToken) { idToken = refreshedToken; r2Result = await _r2UploadService.uploadTelemetryBundle( idToken: idToken, @@ -103,15 +400,39 @@ class FirebaseSyncProvider implements SyncProvider { } if (!r2Result.success) { - if (r2Result.requiresAuth) return SyncResult.authRequired(message: r2Result.errorMessage ?? 'R2 Gateway authentication required'); - if (r2Result.isRetryable) return SyncResult.temporaryFailure(r2Result.errorMessage ?? 'Temporary R2 upload error'); - return SyncResult.permanentFailure(r2Result.errorMessage ?? 'Permanent R2 upload rejection'); + if (r2Result.requiresAuth) { + return SyncResult.authRequired( + message: + r2Result.errorMessage ?? 'R2 Gateway authentication required', + ); + } + if (r2Result.isRetryable) { + return SyncResult.temporaryFailure( + r2Result.errorMessage ?? 'Temporary R2 upload error', + ); + } + return SyncResult.permanentFailure( + r2Result.errorMessage ?? 'Permanent R2 upload rejection', + ); } - final objectKey = r2Result.objectKey ?? 'users/${user.uid}/rides/$remoteRideId/telemetry.zip'; - final events = await (_db.select(_db.rideEvents)..where((e) => e.rideId.equals(item.localRideId))).get(); - final performanceRuns = await (_db.select(_db.performanceRuns)..where((p) => p.rideId.equals(item.localRideId))).get(); - final summary = await (_db.select(_db.fieldTestSummaries)..where((s) => s.rideId.equals(item.localRideId))).getSingleOrNull(); + final expectedObjectKey = + 'users/${user.uid}/rides/$remoteRideId/telemetry.zip'; + final objectKey = r2Result.objectKey ?? expectedObjectKey; + if (objectKey != expectedObjectKey) { + return SyncResult.permanentFailure( + 'R2 Worker returned an unexpected object key ($objectKey); expected $expectedObjectKey.', + ); + } + final events = await (_db.select( + _db.rideEvents, + )..where((e) => e.rideId.equals(item.localRideId))).get(); + final performanceRuns = await (_db.select( + _db.performanceRuns, + )..where((p) => p.rideId.equals(item.localRideId))).get(); + final summary = await (_db.select( + _db.fieldTestSummaries, + )..where((s) => s.rideId.equals(item.localRideId))).getSingleOrNull(); final firestorePayload = { 'id': remoteRideId, @@ -137,12 +458,37 @@ class FirebaseSyncProvider implements SyncProvider { 'motorcycleModel': ride.motorcycleModel, 'motorcycleYear': ride.motorcycleYear, 'motorcycleVariant': ride.motorcycleVariant, - 'hardBrakingEvents': events.where((e) => e.eventType.contains('Braking')).length, - 'extremeLeanEvents': events.where((e) => e.eventType.contains('Lean') || e.eventType.contains('Corner')).length, - 'highSpeedEvents': events.where((e) => e.eventType.contains('Speed')).length, + 'hardBrakingEvents': events + .where((e) => e.eventType.contains('Braking')) + .length, + 'extremeLeanEvents': events + .where( + (e) => + e.eventType.contains('Lean') || + e.eventType.contains('Corner'), + ) + .length, + 'highSpeedEvents': events + .where((e) => e.eventType.contains('Speed')) + .length, 'totalEventsCount': events.length, - 'performanceRuns': performanceRuns.map((r) => {'type': r.type, 'timeMs': r.timeMs, 'topSpeedReached': r.topSpeedReached, 'timestamp': r.timestamp.toUtc().toIso8601String()}).toList(), - 'telemetryBundle': {'provider': 'cloudflare-r2', 'objectKey': objectKey, 'status': 'uploaded', 'bundleSizeBytes': zipBytes.length, 'sha256': sha256Hash}, + 'performanceRuns': performanceRuns + .map( + (r) => { + 'type': r.type, + 'timeMs': r.timeMs, + 'topSpeedReached': r.topSpeedReached, + 'timestamp': r.timestamp.toUtc().toIso8601String(), + }, + ) + .toList(), + 'telemetryBundle': { + 'provider': 'cloudflare-r2', + 'objectKey': objectKey, + 'status': 'uploaded', + 'bundleSizeBytes': zipBytes.length, + 'sha256': sha256Hash, + }, 'telemetryBundlePath': objectKey, 'bundleSizeBytes': zipBytes.length, 'syncVersion': 3, @@ -154,9 +500,21 @@ class FirebaseSyncProvider implements SyncProvider { await _firestore!.collection('users').doc(user.uid).collection('rides').doc(remoteRideId).set(firestorePayload, SetOptions(merge: true)); return SyncResult.success(remoteId: remoteRideId); } on FirebaseException catch (e) { - if (e.code == 'permission-denied' || e.code == 'unauthenticated') return SyncResult.authRequired(message: 'Firebase authorization error: ${e.message}'); - if (e.code == 'unavailable' || e.code == 'network-request-failed' || e.code == 'retry-limit-exceeded') return SyncResult.temporaryFailure('Network or Firebase service temporary error (${e.code}): ${e.message}'); - return SyncResult.permanentFailure('Firebase sync error (${e.code}): ${e.message}'); + if (e.code == 'permission-denied' || e.code == 'unauthenticated') { + return SyncResult.authRequired( + message: 'Firebase authorization error: ${e.message}', + ); + } + if (e.code == 'unavailable' || + e.code == 'network-request-failed' || + e.code == 'retry-limit-exceeded') { + return SyncResult.temporaryFailure( + 'Network or Firebase service temporary error (${e.code}): ${e.message}', + ); + } + return SyncResult.permanentFailure( + 'Firebase sync error (${e.code}): ${e.message}', + ); } catch (e) { return SyncResult.temporaryFailure('Unexpected sync exception: $e'); } diff --git a/lib/services/sync/github_sync_provider.dart b/lib/services/sync/github_sync_provider.dart index ab66e0c..1f1a95c 100644 --- a/lib/services/sync/github_sync_provider.dart +++ b/lib/services/sync/github_sync_provider.dart @@ -9,10 +9,7 @@ class GitHubSyncProvider implements SyncProvider { final GitHubSyncService service; final GitHubAuthService auth; - GitHubSyncProvider({ - required this.service, - required this.auth, - }); + GitHubSyncProvider({required this.service, required this.auth}); @override String get providerId => 'github'; @@ -23,12 +20,21 @@ class GitHubSyncProvider implements SyncProvider { @override Future requiresAuthentication() async => auth.getToken() == null; + @override + Future isRideConfirmedInCloud(String remoteRideId) async => false; + + @override + Future verifyCloudConfirmation( + String remoteRideId, + ) async => CloudConfirmationResult.missing(remoteRideId: remoteRideId); + @override Future uploadRide(SyncItem item) async { final hasAuth = auth.getToken() != null; if (!hasAuth) { return SyncResult.authRequired( - message: 'GitHub developer export requires an explicitly configured GitHub token.', + message: + 'GitHub developer export requires an explicitly configured GitHub token.', ); } diff --git a/lib/services/sync/r2_upload_service.dart b/lib/services/sync/r2_upload_service.dart index 986e095..f32dd42 100644 --- a/lib/services/sync/r2_upload_service.dart +++ b/lib/services/sync/r2_upload_service.dart @@ -2,6 +2,95 @@ import 'dart:async'; import 'dart:convert'; import 'package:flutter/foundation.dart'; import 'package:http/http.dart' as http; +import 'sync_provider.dart'; + +@immutable +class R2VerificationMetadata { + final bool verified; + final String remoteRideId; + final String? objectKey; + final int? size; + final String? sha256; + final String? etag; + + const R2VerificationMetadata({ + required this.verified, + required this.remoteRideId, + this.objectKey, + this.size, + this.sha256, + this.etag, + }); +} + +@immutable +class R2VerificationResponse { + final CloudConfirmationStatus status; + final String? errorMessage; + final int? statusCode; + final R2VerificationMetadata? metadata; + + const R2VerificationResponse({ + required this.status, + this.errorMessage, + this.statusCode, + this.metadata, + }); + + const R2VerificationResponse.confirmed( + R2VerificationMetadata metadata, { + int statusCode = 200, + }) : this( + status: CloudConfirmationStatus.confirmed, + statusCode: statusCode, + metadata: metadata, + ); + + const R2VerificationResponse.legacyUnverified( + R2VerificationMetadata metadata, { + int statusCode = 200, + String? message, + }) : this( + status: CloudConfirmationStatus.legacyUnverified, + statusCode: statusCode, + metadata: metadata, + errorMessage: message, + ); + + const R2VerificationResponse.missing(String message, {int statusCode = 404}) + : this( + status: CloudConfirmationStatus.missing, + errorMessage: message, + statusCode: statusCode, + ); + + const R2VerificationResponse.inconsistent( + String message, { + int statusCode = 409, + }) : this( + status: CloudConfirmationStatus.inconsistent, + errorMessage: message, + statusCode: statusCode, + ); + + const R2VerificationResponse.unauthorized( + String message, { + int statusCode = 401, + }) : this( + status: CloudConfirmationStatus.unauthorized, + errorMessage: message, + statusCode: statusCode, + ); + + const R2VerificationResponse.temporarilyUnverifiable( + String message, { + int? statusCode, + }) : this( + status: CloudConfirmationStatus.temporarilyUnverifiable, + errorMessage: message, + statusCode: statusCode, + ); +} @immutable class R2UploadResult { @@ -12,59 +101,381 @@ class R2UploadResult { final int? statusCode; final bool isRetryable; final bool requiresAuth; - const R2UploadResult({required this.success, this.objectKey, this.bytesWritten, this.errorMessage, this.statusCode, this.isRetryable = true, this.requiresAuth = false}); - const R2UploadResult.success({required String objectKey, int? bytesWritten}) : this(success: true, objectKey: objectKey, bytesWritten: bytesWritten, statusCode: 200, isRetryable: false); - const R2UploadResult.authRequired(String message, {int statusCode = 401}) : this(success: false, errorMessage: message, statusCode: statusCode, isRetryable: true, requiresAuth: true); - const R2UploadResult.temporaryFailure(String message, {int? statusCode}) : this(success: false, errorMessage: message, statusCode: statusCode, isRetryable: true); - const R2UploadResult.permanentFailure(String message, {int? statusCode}) : this(success: false, errorMessage: message, statusCode: statusCode, isRetryable: false); + const R2UploadResult({ + required this.success, + this.objectKey, + this.bytesWritten, + this.errorMessage, + this.statusCode, + this.isRetryable = true, + this.requiresAuth = false, + }); + const R2UploadResult.success({required String objectKey, int? bytesWritten}) + : this( + success: true, + objectKey: objectKey, + bytesWritten: bytesWritten, + statusCode: 200, + isRetryable: false, + ); + const R2UploadResult.authRequired(String message, {int statusCode = 401}) + : this( + success: false, + errorMessage: message, + statusCode: statusCode, + isRetryable: true, + requiresAuth: true, + ); + const R2UploadResult.temporaryFailure(String message, {int? statusCode}) + : this( + success: false, + errorMessage: message, + statusCode: statusCode, + isRetryable: true, + ); + const R2UploadResult.permanentFailure(String message, {int? statusCode}) + : this( + success: false, + errorMessage: message, + statusCode: statusCode, + isRetryable: false, + ); } /// Uploads immutable ride telemetry bundles through the authenticated Worker gateway. class R2UploadService { - static const String defaultWorkerUrl = String.fromEnvironment('R2_WORKER_URL', defaultValue: 'https://motorsignal-r2-worker.bponders12.workers.dev'); + static const String defaultWorkerUrl = String.fromEnvironment( + 'R2_WORKER_URL', + defaultValue: 'https://motorsignal-r2-worker.bponders12.workers.dev', + ); final http.Client _client; final String _workerBaseUrl; - R2UploadService({http.Client? client, String? workerBaseUrl}) : _client = client ?? http.Client(), _workerBaseUrl = (workerBaseUrl ?? defaultWorkerUrl).replaceAll(RegExp(r'/+$'), ''); + R2UploadService({http.Client? client, String? workerBaseUrl}) + : _client = client ?? http.Client(), + _workerBaseUrl = (workerBaseUrl ?? defaultWorkerUrl).replaceAll( + RegExp(r'/+$'), + '', + ); String get workerBaseUrl => _workerBaseUrl; - Future uploadTelemetryBundle({required String idToken, required String remoteRideId, required Uint8List zipBytes, required String sha256Hash, required String appVersion, Duration timeout = const Duration(seconds: 45)}) async { - if (idToken.trim().isEmpty) return const R2UploadResult.authRequired('Firebase ID token is required for R2 upload.'); - if (zipBytes.isEmpty) return const R2UploadResult.permanentFailure('Telemetry bundle is empty.', statusCode: 400); + Future uploadTelemetryBundle({ + required String idToken, + required String remoteRideId, + required Uint8List zipBytes, + required String sha256Hash, + required String appVersion, + Duration timeout = const Duration(seconds: 45), + }) async { + if (idToken.trim().isEmpty) { + return const R2UploadResult.authRequired( + 'Firebase ID token is required for R2 upload.', + ); + } + if (zipBytes.isEmpty) { + return const R2UploadResult.permanentFailure( + 'Telemetry bundle is empty.', + statusCode: 400, + ); + } // Prevent path traversal at the client boundary. The Worker remains the // authoritative verifier of SHA-256 and Firebase UID ownership. - if (!RegExp(r'^ride_[A-Za-z0-9_-]+$').hasMatch(remoteRideId)) return const R2UploadResult.permanentFailure('Invalid remote ride identifier.', statusCode: 400); - if (sha256Hash.trim().isEmpty) return const R2UploadResult.permanentFailure('Telemetry bundle SHA-256 is required.', statusCode: 400); + if (!RegExp(r'^ride_[A-Za-z0-9_-]+$').hasMatch(remoteRideId)) { + return const R2UploadResult.permanentFailure( + 'Invalid remote ride identifier.', + statusCode: 400, + ); + } + if (sha256Hash.trim().isEmpty) { + return const R2UploadResult.permanentFailure( + 'Telemetry bundle SHA-256 is required.', + statusCode: 400, + ); + } try { - final response = await _client.put(Uri.parse('$_workerBaseUrl/r2/upload'), headers: { - 'Authorization': 'Bearer ${idToken.trim()}', - 'Content-Type': 'application/zip', - 'X-Remote-Ride-Id': remoteRideId, - 'X-Bundle-Sha256': sha256Hash.trim().toLowerCase(), - 'X-App-Version': appVersion, - }, body: zipBytes).timeout(timeout); + final response = await _client + .put( + Uri.parse('$_workerBaseUrl/r2/upload'), + headers: { + 'Authorization': 'Bearer ${idToken.trim()}', + 'Content-Type': 'application/zip', + 'X-Remote-Ride-Id': remoteRideId, + 'X-Bundle-Sha256': sha256Hash.trim().toLowerCase(), + 'X-App-Version': appVersion, + }, + body: zipBytes, + ) + .timeout(timeout); final status = response.statusCode; if (status >= 200 && status < 300) { try { final decoded = jsonDecode(response.body); - if (decoded is! Map) throw const FormatException('Invalid worker response'); + if (decoded is! Map) { + throw const FormatException('Invalid worker response'); + } final data = Map.from(decoded); final objectKey = data['objectKey']; - if (objectKey is! String || !objectKey.endsWith('/rides/$remoteRideId/telemetry.zip') || !objectKey.startsWith('users/')) return const R2UploadResult.permanentFailure('R2 worker returned an invalid object key.'); + if (objectKey is! String || + !objectKey.endsWith('/rides/$remoteRideId/telemetry.zip') || + !objectKey.startsWith('users/')) { + return const R2UploadResult.permanentFailure( + 'R2 worker returned an invalid object key.', + ); + } final size = data['size']; - final bytes = size is num && size >= 0 ? size.toInt() : zipBytes.length; - return R2UploadResult.success(objectKey: objectKey, bytesWritten: bytes); + final bytes = size is num && size >= 0 + ? size.toInt() + : zipBytes.length; + return R2UploadResult.success( + objectKey: objectKey, + bytesWritten: bytes, + ); } catch (e) { - return R2UploadResult.temporaryFailure('R2 upload response could not be verified: $e', statusCode: status); + return R2UploadResult.temporaryFailure( + 'R2 upload response could not be verified: $e', + statusCode: status, + ); } } - if (status == 401 || status == 403) return R2UploadResult.authRequired('R2 Worker authorization denied ($status).', statusCode: status); - if (status == 400 || status == 404 || status == 409 || status == 422) return R2UploadResult.permanentFailure('R2 Worker rejected the bundle ($status).', statusCode: status); - return R2UploadResult.temporaryFailure('R2 Worker temporary error ($status).', statusCode: status); + if (status == 401 || status == 403) { + return R2UploadResult.authRequired( + 'R2 Worker authorization denied ($status).', + statusCode: status, + ); + } + if (status == 400 || status == 404 || status == 409 || status == 422) { + return R2UploadResult.permanentFailure( + 'R2 Worker rejected the bundle ($status).', + statusCode: status, + ); + } + return R2UploadResult.temporaryFailure( + 'R2 Worker temporary error ($status).', + statusCode: status, + ); } on TimeoutException { - return R2UploadResult.temporaryFailure('Upload to Cloudflare R2 timed out after ${timeout.inSeconds} seconds.'); + return R2UploadResult.temporaryFailure( + 'Upload to Cloudflare R2 timed out after ${timeout.inSeconds} seconds.', + ); } catch (e) { - return R2UploadResult.temporaryFailure('Network error uploading to Cloudflare R2: $e'); + return R2UploadResult.temporaryFailure( + 'Network error uploading to Cloudflare R2: $e', + ); + } + } + + /// Lightweight verification of telemetry bundle storage and integrity in Cloudflare R2. + /// Queries the worker via /r2/verify to verify existence, metadata, and hash match + /// without downloading the full ZIP archive. + /// + /// CRITICAL ARCHITECTURAL DISTINCTION: + /// R2 HEAD / metadata checks prove that an object exists in Cloudflare R2 and that its stored + /// metadata (SHA-256 hash, byte size, object key, remote ride identity) matches the expected + /// Firestore and local contract. + /// They do NOT independently prove that the stored ZIP archive's binary payload is readable, + /// decompresses cleanly, or is uncorrupted at rest. Full bundle downloads on startup are + /// intentionally avoided to protect mobile bandwidth and startup latency. For future stronger + /// verification, background sampling or periodic full restore-integrity checks should be + /// scheduled rather than downloading every bundle on every launch or reconciliation pass. + Future verifyTelemetryBundle({ + required String idToken, + required String remoteRideId, + String? expectedSha256, + int? expectedSize, + Duration timeout = const Duration(seconds: 15), + }) async { + if (idToken.trim().isEmpty) { + return const R2VerificationResponse.unauthorized( + 'Firebase ID token is required for verification.', + ); + } + if (!RegExp(r'^ride_[A-Za-z0-9_-]+$').hasMatch(remoteRideId)) { + return const R2VerificationResponse.inconsistent( + 'Invalid remote ride identifier format.', + ); + } + + try { + final uri = Uri.parse('$_workerBaseUrl/r2/verify?rideId=$remoteRideId'); + final response = await _client + .get( + uri, + headers: { + 'Authorization': 'Bearer ${idToken.trim()}', + 'Accept': 'application/json', + }, + ) + .timeout(timeout); + + final status = response.statusCode; + + if (status >= 200 && status < 300) { + try { + final decoded = jsonDecode(response.body); + if (decoded is! Map) { + return const R2VerificationResponse.inconsistent( + 'Worker verification returned invalid response structure.', + ); + } + final data = Map.from(decoded); + final respRideId = data['remoteRideId']?.toString(); + if (respRideId == null || respRideId != remoteRideId) { + return R2VerificationResponse.inconsistent( + 'Worker verification rideId ($respRideId) does not match expected ($remoteRideId).', + statusCode: status, + ); + } + final objectKey = data['objectKey']?.toString(); + if (objectKey == null || + !objectKey.endsWith('/rides/$remoteRideId/telemetry.zip') || + !objectKey.startsWith('users/')) { + return R2VerificationResponse.inconsistent( + 'Worker verification objectKey ($objectKey) does not match expected pattern.', + statusCode: status, + ); + } + final respSize = (data['size'] as num?)?.toInt(); + if (respSize == null || respSize <= 0) { + return R2VerificationResponse.inconsistent( + 'Worker verification returned invalid or non-positive object size ($respSize).', + statusCode: status, + ); + } + if (expectedSize != null && + expectedSize > 0 && + expectedSize != respSize) { + return R2VerificationResponse.inconsistent( + 'Size mismatch: expected $expectedSize bytes but got $respSize bytes.', + statusCode: 422, + ); + } + + final respSha256 = data['sha256']?.toString(); + if (expectedSha256 != null && expectedSha256.trim().isNotEmpty) { + if (respSha256 == null || respSha256.trim().isEmpty) { + return R2VerificationResponse.inconsistent( + 'Missing required SHA-256 in R2 worker response for ride with expected hash $expectedSha256.', + statusCode: status, + ); + } + if (expectedSha256.trim().toLowerCase() != + respSha256.trim().toLowerCase()) { + return R2VerificationResponse.inconsistent( + 'SHA-256 checksum mismatch: expected $expectedSha256 but got $respSha256.', + statusCode: 422, + ); + } + } + + final metadata = R2VerificationMetadata( + verified: true, + remoteRideId: remoteRideId, + objectKey: objectKey, + size: respSize, + sha256: respSha256, + etag: data['etag']?.toString(), + ); + + // If bundle lacks modern checksum metadata, report as legacyUnverified rather than confirmed. + if (expectedSha256 == null || + expectedSha256.trim().isEmpty || + respSha256 == null || + respSha256.trim().isEmpty) { + return R2VerificationResponse.legacyUnverified( + metadata, + statusCode: status, + message: + 'Historical bundle verified in R2 without full checksum metadata.', + ); + } + + return R2VerificationResponse.confirmed(metadata, statusCode: status); + } catch (e) { + return R2VerificationResponse.temporarilyUnverifiable( + 'Failed to parse worker verification response: $e', + statusCode: status, + ); + } + } + + if (status == 401 || status == 403) { + return R2VerificationResponse.unauthorized( + 'R2 verification unauthorized ($status): ${response.body}', + statusCode: status, + ); + } + + if (status == 404) { + // Distinguish between: + // 1. Live worker without /r2/verify endpoint ("Endpoint not found") -> temporarilyUnverifiable + // 2. Non-JSON 404 (e.g. Cloudflare proxy / edge HTML 404) -> temporarilyUnverifiable + // 3. Telemetry bundle or ride not found from API -> missing + try { + final decoded = jsonDecode(response.body); + if (decoded is Map) { + final error = decoded['error']?.toString(); + final code = decoded['code']?.toString(); + if (error == 'Endpoint not found') { + return const R2VerificationResponse.temporarilyUnverifiable( + 'R2 verification endpoint not yet deployed on worker.', + statusCode: 404, + ); + } + if (code == 'OBJECT_NOT_FOUND' || + code == 'NOT_FOUND' || + error == 'Telemetry bundle not found in R2' || + error == 'Ride not found') { + return R2VerificationResponse.missing( + error ?? 'Telemetry bundle or ride not found in cloud.', + statusCode: 404, + ); + } + } + } catch (_) { + return const R2VerificationResponse.temporarilyUnverifiable( + 'Non-JSON 404 received from R2 gateway; worker endpoint may be undeployed or edge routing failed.', + statusCode: 404, + ); + } + return R2VerificationResponse.missing( + 'Telemetry bundle or ride not found in cloud ($status).', + statusCode: status, + ); + } + + if (status == 409 || status == 422) { + return R2VerificationResponse.inconsistent( + 'Cloud metadata or object integrity rejected ($status): ${response.body}', + statusCode: status, + ); + } + + if (status == 429) { + return R2VerificationResponse.temporarilyUnverifiable( + 'R2 verification rate limited (429).', + statusCode: status, + ); + } + + if (status >= 500) { + return R2VerificationResponse.temporarilyUnverifiable( + 'R2 worker server error ($status).', + statusCode: status, + ); + } + + return R2VerificationResponse.temporarilyUnverifiable( + 'Unexpected status ($status) from R2 worker.', + statusCode: status, + ); + } on TimeoutException { + return R2VerificationResponse.temporarilyUnverifiable( + 'R2 verification timed out after ${timeout.inSeconds}s.', + ); + } catch (e) { + return R2VerificationResponse.temporarilyUnverifiable( + 'Network error verifying with R2 worker: $e', + ); } } } diff --git a/lib/services/sync/sync_manager.dart b/lib/services/sync/sync_manager.dart index 786cf61..a3e1021 100644 --- a/lib/services/sync/sync_manager.dart +++ b/lib/services/sync/sync_manager.dart @@ -6,6 +6,49 @@ import 'sync_provider.dart'; import '../../database/database.dart'; import '../auth/auth_service.dart'; +/// Diagnostic summary returned by [SyncManager.reconcileCompletedRides]. +class CompletedRideReconciliationReport { + final int discoveredCompletedRides; + final int alreadyTrackedCount; + final int recoveredQueuedCount; + final int recoveredAuthRequiredCount; + final int recoveredAlreadySyncedCount; + final int recoveredLegacyUnverifiedCount; + final int foreignOwnerSkippedCount; + final int inconsistentSkippedCount; + final int unverifiablePendingCount; + final int errorCount; + final List errors; + + const CompletedRideReconciliationReport({ + this.discoveredCompletedRides = 0, + this.alreadyTrackedCount = 0, + this.recoveredQueuedCount = 0, + this.recoveredAuthRequiredCount = 0, + this.recoveredAlreadySyncedCount = 0, + this.recoveredLegacyUnverifiedCount = 0, + this.foreignOwnerSkippedCount = 0, + this.inconsistentSkippedCount = 0, + this.unverifiablePendingCount = 0, + this.errorCount = 0, + this.errors = const [], + }); + + int get totalRecovered => + recoveredQueuedCount + + recoveredAuthRequiredCount + + recoveredAlreadySyncedCount; + + @override + String toString() => + 'CompletedRideReconciliationReport(discovered: $discoveredCompletedRides, ' + 'tracked: $alreadyTrackedCount, queued: $recoveredQueuedCount, ' + 'authRequired: $recoveredAuthRequiredCount, alreadySynced: $recoveredAlreadySyncedCount, ' + 'legacyUnverified: $recoveredLegacyUnverifiedCount, ' + 'foreignSkipped: $foreignOwnerSkippedCount, inconsistent: $inconsistentSkippedCount, ' + 'unverifiable: $unverifiablePendingCount, errors: $errorCount)'; +} + /// Central provider-agnostic synchronization coordinator. /// /// Sync work is persisted in [SyncOutbox]. The manager reconciles persisted @@ -16,23 +59,50 @@ class SyncManager extends ChangeNotifier with WidgetsBindingObserver { final Map _providers = {}; String _activeProviderId = 'fake'; bool _isProcessing = false; + Future? _activeReconciliation; + Future? _currentProcessingFuture; + bool _needsAnotherPass = false; DateTime? Function() _nowProvider; final AppDatabase? _db; final AuthService? _authService; - SyncManager(this._outbox, {List? initialProviders, String? defaultProviderId, DateTime? Function()? nowProvider, this._db, this._authService}) : _nowProvider = nowProvider ?? (() => DateTime.now()) { - if (initialProviders != null) { - for (final p in initialProviders) { registerProvider(p); } + SyncManager( + this._outbox, { + List? initialProviders, + String? defaultProviderId, + DateTime? Function()? nowProvider, + this._db, + this._authService, + bool autoStart = true, + }) : _nowProvider = nowProvider ?? (() => DateTime.now()) { + if (defaultProviderId != null) { + _activeProviderId = defaultProviderId; + } else if (initialProviders != null && initialProviders.isNotEmpty) { + _activeProviderId = initialProviders.first.providerId; + } else { + _activeProviderId = 'fake'; + } + if (initialProviders != null) { + for (final p in initialProviders) { + _providers[p.providerId] = p; + } } - if (defaultProviderId != null) _activeProviderId = defaultProviderId; WidgetsBinding.instance.addObserver(this); - unawaited(processQueue()); + if (autoStart) { + unawaited(_startupRecoveryAndProcess()); + } } void setNowProvider(DateTime? Function() provider) => _nowProvider = provider; DateTime _now() => _nowProvider() ?? DateTime.now(); String get activeProviderId => _activeProviderId; - set activeProviderId(String id) { if (_activeProviderId != id) { _activeProviderId = id; notifyListeners(); } } + set activeProviderId(String id) { + if (_activeProviderId != id) { + _activeProviderId = id; + notifyListeners(); + } + } + bool get isProcessing => _isProcessing; int get outboxCount => _outbox.count; List get allItems => _outbox.getAllItems(); @@ -45,29 +115,79 @@ class SyncManager extends ChangeNotifier with WidgetsBindingObserver { for (final item in _outbox.getAllItems()) { if (item.status == SyncStatus.synced) { synced++; - if (item.uploadedAt != null && (lastSuccess == null || item.uploadedAt!.isAfter(lastSuccess))) lastSuccess = item.uploadedAt; - } else if (item.status == SyncStatus.failed || item.status == SyncStatus.cancelled) { + if (item.uploadedAt != null && + (lastSuccess == null || item.uploadedAt!.isAfter(lastSuccess))) { + lastSuccess = item.uploadedAt; + } + } else if (item.status == SyncStatus.failed || + item.status == SyncStatus.cancelled) { failed++; if (item.lastError != null) lastErr = item.lastError; } else { pending++; } } - return SyncManagerState(isProcessing: _isProcessing, pendingCount: pending, syncedCount: synced, failedCount: failed, lastSuccessfulSync: lastSuccess, lastError: lastErr, activeProviderId: _activeProviderId); + return SyncManagerState( + isProcessing: _isProcessing, + pendingCount: pending, + syncedCount: synced, + failedCount: failed, + lastSuccessfulSync: lastSuccess, + lastError: lastErr, + activeProviderId: _activeProviderId, + ); } void registerProvider(SyncProvider provider) { + final wasRegistered = _providers.containsKey(provider.providerId); _providers[provider.providerId] = provider; - if (_activeProviderId == 'none' || _activeProviderId == 'fake') _activeProviderId = provider.providerId; + if (_activeProviderId == 'none' || _activeProviderId == 'fake') { + _activeProviderId = provider.providerId; + } notifyListeners(); + if (!wasRegistered && _db != null) { + unawaited(reconcileCompletedRides(targetProviderId: provider.providerId)); + unawaited(processQueue()); + } } + SyncProvider? getProvider(String providerId) => _providers[providerId]; - Future enqueueRide(int localRideId, {String? targetProviderId, bool autoProcess = true}) async { + Future enqueueRide( + int localRideId, { + String? targetProviderId, + bool autoProcess = true, + }) async { final providerId = targetProviderId ?? _activeProviderId; - if (providerId == 'firebase') { + if (providerId == 'firebase' && _db != null) { final user = _authService?.currentUser; - if (user != null && !user.isAnonymous && _db != null) { + final isAuthenticated = user != null && !user.isAnonymous; + final rideOwner = await _db.getCloudOwnerUid(localRideId); + if (rideOwner != null && isAuthenticated && rideOwner != user.uid) { + final cancelledItem = SyncItem( + localRideId: localRideId, + providerId: providerId, + status: SyncStatus.cancelled, + createdAt: _now(), + lastError: 'Ride belongs to another account and cannot be uploaded', + ); + await _outbox.update(cancelledItem); + notifyListeners(); + return cancelledItem; + } + if (rideOwner != null && !isAuthenticated) { + final cancelledItem = SyncItem( + localRideId: localRideId, + providerId: providerId, + status: SyncStatus.cancelled, + createdAt: _now(), + lastError: 'Cannot upload another user\'s ride while unauthenticated', + ); + await _outbox.update(cancelledItem); + notifyListeners(); + return cancelledItem; + } + if (isAuthenticated && rideOwner == null) { await _db.claimRideForCloudOwner(localRideId, user.uid); } } @@ -77,78 +197,548 @@ class SyncManager extends ChangeNotifier with WidgetsBindingObserver { return item; } - Future backupAllRideIds(Iterable rideIds, {String? targetProviderId}) async { + Future backupAllRideIds( + Iterable rideIds, { + String? targetProviderId, + }) async { var enqueued = 0; for (final rideId in rideIds) { - await enqueueRide(rideId, targetProviderId: targetProviderId, autoProcess: false); + await enqueueRide( + rideId, + targetProviderId: targetProviderId, + autoProcess: false, + ); enqueued++; } if (enqueued > 0) unawaited(processQueue()); return enqueued; } + Future _startupRecoveryAndProcess() async { + await reconcileCompletedRides(); + await processQueue(); + } + Future retryAuthRequired() async { - for (final item in _outbox.getAllItems().where((i) => i.status == SyncStatus.authRequired)) { - await _outbox.update(item.copyWith(status: SyncStatus.queued, clearLastError: true, clearNextRetry: true)); + final user = _authService?.currentUser; + final isAuthenticated = user != null && !user.isAnonymous; + if (isAuthenticated && _db != null) { + await reconcileCompletedRides(); + } + for (final item in _outbox.getAllItems().where( + (i) => i.status == SyncStatus.authRequired, + )) { + if (item.providerId == 'firebase') { + if (!isAuthenticated) continue; + if (_db != null) { + final rideOwner = await _db.getCloudOwnerUid(item.localRideId); + if (rideOwner != null && rideOwner != user.uid) { + await _outbox.update( + item.copyWith( + status: SyncStatus.cancelled, + lastError: + 'Ride belongs to another account and cannot be retried', + clearNextRetry: true, + ), + ); + continue; + } + if (rideOwner == null) { + await _db.claimRideForCloudOwner(item.localRideId, user.uid); + } + } + } + await _outbox.update( + item.copyWith( + status: SyncStatus.queued, + clearLastError: true, + clearNextRetry: true, + ), + ); } await processQueue(); } + /// Discovers completed rides in local SQLite that are missing from [SyncOutbox] + /// or need cloud confirmation recovery. + Future reconcileCompletedRides({ + String? targetProviderId, + }) async { + if (_activeReconciliation != null) { + return _activeReconciliation!; + } + final future = _performReconcileCompletedRides( + targetProviderId: targetProviderId, + ); + _activeReconciliation = future; + try { + return await future; + } finally { + _activeReconciliation = null; + } + } + + Future _performReconcileCompletedRides({ + String? targetProviderId, + }) async { + if (_db == null) { + return const CompletedRideReconciliationReport(); + } + + final providerId = targetProviderId ?? _activeProviderId; + final provider = _providers[providerId]; + final user = _authService?.currentUser; + final isAuthenticated = user != null && !user.isAnonymous; + + int discovered = 0; + int alreadyTracked = 0; + int recoveredQueued = 0; + int recoveredAuthRequired = 0; + int recoveredAlreadySynced = 0; + int recoveredLegacyUnverified = 0; + int foreignOwnerSkipped = 0; + int inconsistentSkipped = 0; + int unverifiablePending = 0; + int errorCount = 0; + final List errors = []; + + try { + final completedRides = await (_db.select( + _db.rides, + )..where((r) => r.endTime.isNotNull())).get(); + discovered = completedRides.length; + + for (final ride in completedRides) { + try { + // Check if already tracked in outbox for this provider + final existingItem = _outbox.getItem(ride.id, providerId); + if (existingItem != null) { + if (!existingItem.status.isTerminal && + ride.cloudOwnerUid != null && + isAuthenticated && + ride.cloudOwnerUid != user.uid && + providerId == 'firebase') { + await _outbox.update( + existingItem.copyWith( + status: SyncStatus.cancelled, + lastError: + 'Ride belongs to another account and cannot be uploaded', + clearNextRetry: true, + ), + ); + foreignOwnerSkipped++; + continue; + } + alreadyTracked++; + continue; + } + + // Foreign account check: never claim or upload another user's ride + if (ride.cloudOwnerUid != null && + isAuthenticated && + ride.cloudOwnerUid != user.uid) { + foreignOwnerSkipped++; + continue; + } + if (ride.cloudOwnerUid != null && !isAuthenticated) { + foreignOwnerSkipped++; + continue; + } + + // Stable cloud identity check + final remoteRideId = await _db.ensureCloudRideId(ride.id); + + // Deep verification check in cloud backend + final confirmation = provider != null + ? await provider.verifyCloudConfirmation(remoteRideId) + : const CloudConfirmationResult( + status: CloudConfirmationStatus.missing, + ); + + switch (confirmation.status) { + case CloudConfirmationStatus.confirmed: + if (ride.cloudOwnerUid == null && + isAuthenticated && + providerId == 'firebase') { + await _db.claimRideForCloudOwner(ride.id, user.uid); + } + final syncedItem = SyncItem( + localRideId: ride.id, + providerId: providerId, + status: SyncStatus.synced, + remoteId: remoteRideId, + createdAt: ride.endTime ?? ride.startTime, + uploadedAt: _now(), + ); + await _outbox.update(syncedItem); + recoveredAlreadySynced++; + break; + + case CloudConfirmationStatus.legacyUnverified: + if (ride.cloudOwnerUid == null && + isAuthenticated && + providerId == 'firebase') { + await _db.claimRideForCloudOwner(ride.id, user.uid); + } + final legacySyncedItem = SyncItem( + localRideId: ride.id, + providerId: providerId, + status: SyncStatus.synced, + remoteId: remoteRideId, + createdAt: ride.endTime ?? ride.startTime, + uploadedAt: _now(), + lastError: + 'Legacy ride cloud-confirmed without modern checksum', + ); + await _outbox.update(legacySyncedItem); + recoveredLegacyUnverified++; + recoveredAlreadySynced++; + break; + + case CloudConfirmationStatus.missing: + final providerRequiresAuth = + provider != null && await provider.requiresAuthentication(); + + if (providerRequiresAuth || + (!isAuthenticated && providerId == 'firebase')) { + final authReqItem = SyncItem( + localRideId: ride.id, + providerId: providerId, + status: SyncStatus.authRequired, + createdAt: ride.endTime ?? ride.startTime, + lastError: + 'Authentication required for provider "$providerId"', + ); + await _outbox.update(authReqItem); + recoveredAuthRequired++; + } else { + if (ride.cloudOwnerUid == null && + isAuthenticated && + providerId == 'firebase') { + await _db.claimRideForCloudOwner(ride.id, user.uid); + } + final queuedItem = SyncItem( + localRideId: ride.id, + providerId: providerId, + status: SyncStatus.queued, + createdAt: ride.endTime ?? ride.startTime, + ); + await _outbox.update(queuedItem); + recoveredQueued++; + } + break; + + case CloudConfirmationStatus.inconsistent: + inconsistentSkipped++; + errors.add( + 'Ride #${ride.id} ($remoteRideId) inconsistent in cloud: ${confirmation.message}', + ); + final inconsistentItem = SyncItem( + localRideId: ride.id, + providerId: providerId, + status: SyncStatus.failed, + remoteId: remoteRideId, + createdAt: ride.endTime ?? ride.startTime, + lastError: 'Cloud data inconsistent: ${confirmation.message}', + attemptCount: 1, + ); + await _outbox.update(inconsistentItem); + break; + + case CloudConfirmationStatus.unauthorized: + final providerRequiresAuth = + provider != null && await provider.requiresAuthentication(); + + if (!isAuthenticated || providerRequiresAuth) { + final authReqItem = SyncItem( + localRideId: ride.id, + providerId: providerId, + status: SyncStatus.authRequired, + createdAt: ride.endTime ?? ride.startTime, + lastError: + 'Authentication required for provider "$providerId"', + ); + await _outbox.update(authReqItem); + recoveredAuthRequired++; + } else { + foreignOwnerSkipped++; + errors.add( + 'Ride #${ride.id} ($remoteRideId) unauthorized: ${confirmation.message}', + ); + } + break; + + case CloudConfirmationStatus.temporarilyUnverifiable: + unverifiablePending++; + final retryItem = SyncItem( + localRideId: ride.id, + providerId: providerId, + status: SyncStatus.retryWait, + remoteId: remoteRideId, + createdAt: ride.endTime ?? ride.startTime, + nextRetryAt: _now().add(const Duration(minutes: 2)), + lastError: + 'Cloud temporarily unverifiable: ${confirmation.message}', + ); + await _outbox.update(retryItem); + break; + } + } catch (itemErr) { + errorCount++; + errors.add('Ride #${ride.id}: $itemErr'); + } + } + } catch (e) { + errorCount++; + errors.add('Reconciliation failed: $e'); + } + + final report = CompletedRideReconciliationReport( + discoveredCompletedRides: discovered, + alreadyTrackedCount: alreadyTracked, + recoveredQueuedCount: recoveredQueued, + recoveredAuthRequiredCount: recoveredAuthRequired, + recoveredAlreadySyncedCount: recoveredAlreadySynced, + recoveredLegacyUnverifiedCount: recoveredLegacyUnverified, + foreignOwnerSkippedCount: foreignOwnerSkipped, + inconsistentSkippedCount: inconsistentSkipped, + unverifiablePendingCount: unverifiablePending, + errorCount: errorCount, + errors: errors, + ); + + if (report.totalRecovered > 0) { + notifyListeners(); + } + return report; + } + static Duration calculateRetryDelay(int attemptCount) { switch (attemptCount) { - case 1: return const Duration(seconds: 5); - case 2: return const Duration(seconds: 30); - case 3: return const Duration(minutes: 2); - case 4: return const Duration(minutes: 5); - case 5: return const Duration(minutes: 15); - default: return const Duration(minutes: 30); + case 1: + return const Duration(seconds: 5); + case 2: + return const Duration(seconds: 30); + case 3: + return const Duration(minutes: 2); + case 4: + return const Duration(minutes: 5); + case 5: + return const Duration(minutes: 15); + default: + return const Duration(minutes: 30); } } @override void didChangeAppLifecycleState(AppLifecycleState state) { - if (state == AppLifecycleState.resumed) unawaited(processQueue()); + if (state == AppLifecycleState.resumed) { + unawaited(_startupRecoveryAndProcess()); + } } Future processQueue() async { - if (_isProcessing) return; + if (_currentProcessingFuture != null) { + _needsAnotherPass = true; + return _currentProcessingFuture!; + } + final future = _performProcessQueue(); + _currentProcessingFuture = future; + try { + await future; + } finally { + _currentProcessingFuture = null; + } + } + + Future _performProcessQueue() async { _isProcessing = true; notifyListeners(); try { - final now = _now(); - for (final item in _outbox.getEligibleItems(now)) { - final provider = _providers[item.providerId]; - if (provider == null) { - await _outbox.update(item.copyWith(status: SyncStatus.failed, lastError: 'Provider "${item.providerId}" not registered', lastAttemptAt: now)); - continue; + do { + _needsAnotherPass = false; + final now = _now(); + for (final item in _outbox.getEligibleItems(now)) { + final provider = _providers[item.providerId]; + if (provider == null) { + await _outbox.update( + item.copyWith( + status: SyncStatus.failed, + lastError: 'Provider "${item.providerId}" not registered', + lastAttemptAt: now, + ), + ); + continue; + } + + // 1. Confirm local database record exists + final ride = _db != null + ? await (_db.select(_db.rides) + ..where((r) => r.id.equals(item.localRideId))) + .getSingleOrNull() + : null; + if (_db != null && ride == null) { + await _outbox.update( + item.copyWith( + status: SyncStatus.failed, + lastError: + 'Local ride #${item.localRideId} not found in database', + lastAttemptAt: now, + clearNextRetry: true, + ), + ); + continue; + } + + // 2. Immediate pre-upload ownership and account checks + // 2. Immediate pre-upload ownership and account checks + if (item.providerId == 'firebase' && _authService != null) { + final user = _authService.currentUser; + final isAuthenticated = user != null && !user.isAnonymous; + if (!isAuthenticated) { + await _outbox.update( + item.copyWith( + status: SyncStatus.authRequired, + lastError: + 'Authentication required for provider "${item.providerId}"', + lastAttemptAt: now, + ), + ); + continue; + } + if (ride != null && + ride.cloudOwnerUid != null && + ride.cloudOwnerUid != user.uid) { + await _outbox.update( + item.copyWith( + status: SyncStatus.cancelled, + lastError: + 'Ride belongs to another account and cannot be uploaded', + lastAttemptAt: now, + clearNextRetry: true, + ), + ); + continue; + } + if (ride != null && ride.cloudOwnerUid == null && _db != null) { + final claimed = await _db.claimRideForCloudOwner( + ride.id, + user.uid, + ); + if (!claimed) { + await _outbox.update( + item.copyWith( + status: SyncStatus.cancelled, + lastError: 'Failed to claim ride ownership for upload', + lastAttemptAt: now, + clearNextRetry: true, + ), + ); + continue; + } + } + } + + if (!await provider.isAvailable()) { + await _outbox.update( + item.copyWith( + status: SyncStatus.waitingForNetwork, + lastAttemptAt: now, + ), + ); + continue; + } + if (await provider.requiresAuthentication()) { + await _outbox.update( + item.copyWith( + status: SyncStatus.authRequired, + lastError: + 'Authentication required for provider "${item.providerId}"', + lastAttemptAt: now, + ), + ); + continue; + } + + final uidBeforeUpload = _authService?.currentUser?.uid; + final uploadingItem = item.copyWith( + status: SyncStatus.uploading, + lastAttemptAt: now, + ); + await _outbox.update(uploadingItem); + SyncResult result; + try { + result = await provider.uploadRide(uploadingItem); + } catch (e) { + result = SyncResult.temporaryFailure( + 'Unhandled provider exception: $e', + timestamp: now, + ); + } + + // 3. Post-upload identity integrity check: verify user didn't logout/switch mid-flight + final uidAfterUpload = _authService?.currentUser?.uid; + if (item.providerId == 'firebase' && + _authService != null && + uidBeforeUpload != uidAfterUpload) { + await _outbox.update( + uploadingItem.copyWith( + status: SyncStatus.authRequired, + lastError: 'User changed or logged out during upload', + clearNextRetry: true, + ), + ); + continue; + } + + // 4. In-flight cancellation protection: verify item was not cancelled while uploading + final currentItem = _outbox.getItem( + item.localRideId, + item.providerId, + ); + if (currentItem?.status == SyncStatus.cancelled) { + continue; + } + + if (result.success) { + await _outbox.update( + uploadingItem.copyWith( + status: SyncStatus.synced, + remoteId: result.remoteId, + uploadedAt: result.timestamp, + clearLastError: true, + clearNextRetry: true, + ), + ); + } else if (result.requiresAuth) { + await _outbox.update( + uploadingItem.copyWith( + status: SyncStatus.authRequired, + lastError: result.errorMessage, + ), + ); + } else if (result.isRetryable) { + final attempts = uploadingItem.attemptCount + 1; + await _outbox.update( + uploadingItem.copyWith( + status: SyncStatus.retryWait, + attemptCount: attempts, + nextRetryAt: now.add(calculateRetryDelay(attempts)), + lastError: result.errorMessage, + ), + ); + } else { + await _outbox.update( + uploadingItem.copyWith( + status: SyncStatus.failed, + attemptCount: uploadingItem.attemptCount + 1, + lastError: result.errorMessage, + clearNextRetry: true, + ), + ); + } } - if (!await provider.isAvailable()) { - await _outbox.update(item.copyWith(status: SyncStatus.waitingForNetwork, lastAttemptAt: now)); - continue; - } - if (await provider.requiresAuthentication()) { - await _outbox.update(item.copyWith(status: SyncStatus.authRequired, lastError: 'Authentication required for provider "${item.providerId}"', lastAttemptAt: now)); - continue; - } - final uploadingItem = item.copyWith(status: SyncStatus.uploading, lastAttemptAt: now); - await _outbox.update(uploadingItem); - SyncResult result; - try { - result = await provider.uploadRide(uploadingItem); - } catch (e) { - result = SyncResult.temporaryFailure('Unhandled provider exception: $e', timestamp: now); - } - if (result.success) { - await _outbox.update(uploadingItem.copyWith(status: SyncStatus.synced, remoteId: result.remoteId, uploadedAt: result.timestamp, clearLastError: true, clearNextRetry: true)); - } else if (result.requiresAuth) { - await _outbox.update(uploadingItem.copyWith(status: SyncStatus.authRequired, lastError: result.errorMessage)); - } else if (result.isRetryable) { - final attempts = uploadingItem.attemptCount + 1; - await _outbox.update(uploadingItem.copyWith(status: SyncStatus.retryWait, attemptCount: attempts, nextRetryAt: now.add(calculateRetryDelay(attempts)), lastError: result.errorMessage)); - } else { - await _outbox.update(uploadingItem.copyWith(status: SyncStatus.failed, attemptCount: uploadingItem.attemptCount + 1, lastError: result.errorMessage, clearNextRetry: true)); - } - } + } while (_needsAnotherPass); } finally { _isProcessing = false; notifyListeners(); @@ -164,9 +754,37 @@ class SyncManager extends ChangeNotifier with WidgetsBindingObserver { } Future retryAllPending() async { - final retryable = _outbox.getAllItems().where((i) => i.status.isPending || i.status == SyncStatus.failed).toList(growable: false); + final user = _authService?.currentUser; + final isAuthenticated = user != null && !user.isAnonymous; + final database = _db; + final retryable = _outbox + .getAllItems() + .where((i) => i.status.isPending || i.status == SyncStatus.failed) + .toList(growable: false); for (final item in retryable) { - await _outbox.update(item.copyWith(status: SyncStatus.queued, clearLastError: true, clearNextRetry: true)); + if (item.providerId == 'firebase' && + database != null && + _authService != null) { + final rideOwner = await database.getCloudOwnerUid(item.localRideId); + if (rideOwner != null && isAuthenticated && rideOwner != user.uid) { + await _outbox.update( + item.copyWith( + status: SyncStatus.cancelled, + lastError: + 'Ride belongs to another account and cannot be retried', + clearNextRetry: true, + ), + ); + continue; + } + } + await _outbox.update( + item.copyWith( + status: SyncStatus.queued, + clearLastError: true, + clearNextRetry: true, + ), + ); } await processQueue(); } diff --git a/lib/services/sync/sync_provider.dart b/lib/services/sync/sync_provider.dart index 86abe4f..aafb41f 100644 --- a/lib/services/sync/sync_provider.dart +++ b/lib/services/sync/sync_provider.dart @@ -1,6 +1,137 @@ import 'dart:async'; import 'sync_models.dart'; +/// Explicit cloud confirmation states representing the exact verification status of a remote ride. +enum CloudConfirmationStatus { + /// Firestore metadata and corresponding R2 bundle are consistently verified. + confirmed, + + /// Required Firestore metadata or R2 object is absent. + missing, + + /// Ride identity, owner, bundle reference, size, or checksum does not match. + inconsistent, + + /// The current user cannot access or verify the ride. + unauthorized, + + /// Network outage, timeout, rate limit, or transient backend failure. + temporarilyUnverifiable, + + /// Bundle exists in cloud (e.g. historical ride) but lacks modern cryptographic checksum verification. + legacyUnverified, +} + +/// Explicit verification report for a cloud ride. +class CloudConfirmationResult { + final CloudConfirmationStatus status; + final String? message; + final String? remoteRideId; + final String? ownerUid; + final String? objectKey; + final int? sizeBytes; + final String? sha256; + + const CloudConfirmationResult({ + required this.status, + this.message, + this.remoteRideId, + this.ownerUid, + this.objectKey, + this.sizeBytes, + this.sha256, + }); + + const CloudConfirmationResult.confirmed({ + required String remoteRideId, + String? ownerUid, + String? objectKey, + int? sizeBytes, + String? sha256, + String? message, + }) : this( + status: CloudConfirmationStatus.confirmed, + remoteRideId: remoteRideId, + ownerUid: ownerUid, + objectKey: objectKey, + sizeBytes: sizeBytes, + sha256: sha256, + message: message, + ); + + const CloudConfirmationResult.missing({ + required String remoteRideId, + String? message, + }) : this( + status: CloudConfirmationStatus.missing, + remoteRideId: remoteRideId, + message: message, + ); + + const CloudConfirmationResult.inconsistent({ + required String remoteRideId, + String? ownerUid, + String? objectKey, + String? message, + }) : this( + status: CloudConfirmationStatus.inconsistent, + remoteRideId: remoteRideId, + ownerUid: ownerUid, + objectKey: objectKey, + message: message, + ); + + const CloudConfirmationResult.unauthorized({ + required String remoteRideId, + String? ownerUid, + String? message, + }) : this( + status: CloudConfirmationStatus.unauthorized, + remoteRideId: remoteRideId, + ownerUid: ownerUid, + message: message, + ); + + const CloudConfirmationResult.temporarilyUnverifiable({ + required String remoteRideId, + String? message, + }) : this( + status: CloudConfirmationStatus.temporarilyUnverifiable, + remoteRideId: remoteRideId, + message: message, + ); + + const CloudConfirmationResult.legacyUnverified({ + required String remoteRideId, + String? ownerUid, + String? objectKey, + int? sizeBytes, + String? sha256, + String? message, + }) : this( + status: CloudConfirmationStatus.legacyUnverified, + remoteRideId: remoteRideId, + ownerUid: ownerUid, + objectKey: objectKey, + sizeBytes: sizeBytes, + sha256: sha256, + message: message, + ); + + bool get isConfirmed => status == CloudConfirmationStatus.confirmed; + bool get isLegacyUnverified => + status == CloudConfirmationStatus.legacyUnverified; + bool get isMissing => status == CloudConfirmationStatus.missing; + bool get isInconsistent => status == CloudConfirmationStatus.inconsistent; + bool get isUnauthorized => status == CloudConfirmationStatus.unauthorized; + bool get isTemporarilyUnverifiable => + status == CloudConfirmationStatus.temporarilyUnverifiable; + + @override + String toString() => + 'CloudConfirmationResult(status: $status, ride: $remoteRideId, owner: $ownerUid, message: $message)'; +} + /// Abstract contract for synchronization providers (e.g. Firebase, GitHub, Supabase). /// Implementations must not leak provider-specific SDK types into the core sync engine. abstract class SyncProvider { @@ -16,6 +147,19 @@ abstract class SyncProvider { /// Performs the upload of the local ride referenced by [item]. /// Must return a deterministic [SyncResult]. Future uploadRide(SyncItem item); + + /// Checks if the ride with [remoteRideId] is already confirmed uploaded in the cloud. + /// Backward compatible wrapper around [verifyCloudConfirmation]. + Future isRideConfirmedInCloud(String remoteRideId) async { + final result = await verifyCloudConfirmation(remoteRideId); + return result.isConfirmed || result.isLegacyUnverified; + } + + /// Deeply verifies whether [remoteRideId] is confirmed and recoverable in the cloud. + /// Default implementation returns [CloudConfirmationStatus.missing]. + Future verifyCloudConfirmation( + String remoteRideId, + ) async => CloudConfirmationResult.missing(remoteRideId: remoteRideId); } /// Simulated behavior modes for testing the synchronization engine deterministically @@ -36,6 +180,8 @@ class FakeSyncProvider implements SyncProvider { int delayMs; final List uploadedItems = []; final Map uploadAttemptsPerRide = {}; + final Set confirmedRemoteRideIds = {}; + final Map confirmationResults = {}; FakeSyncProvider({ this.providerId = 'fake', @@ -54,6 +200,41 @@ class FakeSyncProvider implements SyncProvider { return behavior == FakeSyncBehavior.authRequired; } + @override + Future isRideConfirmedInCloud(String remoteRideId) async { + final result = await verifyCloudConfirmation(remoteRideId); + return result.isConfirmed || result.isLegacyUnverified; + } + + @override + Future verifyCloudConfirmation( + String remoteRideId, + ) async { + if (confirmationResults.containsKey(remoteRideId)) { + return confirmationResults[remoteRideId]!; + } + if (confirmedRemoteRideIds.contains(remoteRideId)) { + return CloudConfirmationResult.confirmed(remoteRideId: remoteRideId); + } + if (behavior == FakeSyncBehavior.offline || + behavior == FakeSyncBehavior.temporaryFailure) { + return CloudConfirmationResult.temporarilyUnverifiable( + remoteRideId: remoteRideId, + message: 'Fake provider backend unavailable', + ); + } + if (behavior == FakeSyncBehavior.authRequired) { + return CloudConfirmationResult.unauthorized( + remoteRideId: remoteRideId, + message: 'Fake provider requires authentication', + ); + } + return CloudConfirmationResult.missing( + remoteRideId: remoteRideId, + message: 'Ride not found in fake provider', + ); + } + @override Future uploadRide(SyncItem item) async { if (delayMs > 0) { @@ -63,12 +244,30 @@ class FakeSyncProvider implements SyncProvider { uploadAttemptsPerRide[item.localRideId] = (uploadAttemptsPerRide[item.localRideId] ?? 0) + 1; + final existingRemoteId = item.remoteId; + if (existingRemoteId != null) { + final confirmation = await verifyCloudConfirmation(existingRemoteId); + if (confirmation.isConfirmed || confirmation.isLegacyUnverified) { + return SyncResult.success(remoteId: existingRemoteId); + } + if (confirmation.isInconsistent) { + return SyncResult.permanentFailure( + 'Cloud data inconsistent: ${confirmation.message}', + ); + } + } + switch (behavior) { case FakeSyncBehavior.success: uploadedItems.add(item); - return SyncResult.success( - remoteId: 'remote_${item.localRideId}_${DateTime.now().millisecondsSinceEpoch}', + final remoteId = + existingRemoteId ?? + 'remote_${item.localRideId}_${DateTime.now().millisecondsSinceEpoch}'; + confirmedRemoteRideIds.add(remoteId); + confirmationResults[remoteId] = CloudConfirmationResult.confirmed( + remoteRideId: remoteId, ); + return SyncResult.success(remoteId: remoteId); case FakeSyncBehavior.temporaryFailure: return SyncResult.temporaryFailure( @@ -95,5 +294,7 @@ class FakeSyncProvider implements SyncProvider { void reset() { uploadedItems.clear(); uploadAttemptsPerRide.clear(); + confirmedRemoteRideIds.clear(); + confirmationResults.clear(); } } diff --git a/test/cloud_confirmation_contract_test.dart b/test/cloud_confirmation_contract_test.dart new file mode 100644 index 0000000..67205ca --- /dev/null +++ b/test/cloud_confirmation_contract_test.dart @@ -0,0 +1,1108 @@ +import 'dart:async'; +import 'dart:convert'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:fake_cloud_firestore/fake_cloud_firestore.dart'; +import 'package:drift/native.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart' as http_testing; +import 'package:motorcycle_dash/database/database.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/sync/firebase_sync_provider.dart'; +import 'package:motorcycle_dash/services/sync/r2_upload_service.dart'; +import 'package:motorcycle_dash/services/sync/sync_provider.dart'; +import 'package:motorcycle_dash/services/sync/sync_models.dart'; + +class _MockAuthService implements AuthService { + AuthUser? _currentUser; + AuthState _authState = const AuthState.unauthenticated(); + String? idTokenToReturn = 'valid_mock_jwt_token'; + String? refreshedIdToken; + int getIdTokenCalls = 0; + + void setAuthenticated(String uid, String email) { + _currentUser = AuthUser(uid: uid, email: email, isAnonymous: false); + _authState = AuthState.authenticated(_currentUser!); + } + + void setGuest() { + _currentUser = const AuthUser(uid: 'guest_uid', isAnonymous: true); + _authState = AuthState.anonymous(_currentUser!); + } + + void setSignedOut() { + _currentUser = null; + _authState = const AuthState.unauthenticated(); + } + + @override + AuthUser? get currentUser => _currentUser; + @override + AuthState get currentState => _authState; + @override + Stream get authStateChanges => Stream.value(_authState); + @override + bool get isConfigured => true; + @override + bool get isEmailVerified => true; + @override + Future getIdToken([bool forceRefresh = false]) async { + getIdTokenCalls++; + if (forceRefresh && refreshedIdToken != null) { + return refreshedIdToken; + } + return idTokenToReturn; + } + + @override + Future createUserWithEmailAndPassword( + String email, + String password, { + String? displayName, + }) async {} + @override + Future deleteCurrentUser() async {} + @override + void dispose() {} + @override + Future ensureBackendIdentity() async {} + @override + Future reauthenticate({String? password}) async {} + @override + Future reloadCurrentUser() async {} + @override + Future sendEmailVerification() async {} + @override + Future sendPasswordResetEmail(String email) async {} + @override + Future signInAnonymously() async => setGuest(); + @override + Future signInWithEmailAndPassword( + String email, + String password, + ) async => setAuthenticated('user_123', email); + @override + Future signInWithGoogle() async => + setAuthenticated('user_123', 'rider@google.com'); + @override + Future signOut() async => setSignedOut(); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('Trustworthy Cloud Confirmation & R2 Gateway Verification Contract', () { + late AppDatabase db; + late _MockAuthService authService; + late FakeFirebaseFirestore fakeFirestore; + const testUid = 'pilot_apex_42'; + const remoteRideId = 'ride_12345_20261012'; + const validSha256 = + 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855'; + const validSize = 4096; + + setUp(() { + db = AppDatabase.forTesting(NativeDatabase.memory()); + authService = _MockAuthService() + ..setAuthenticated(testUid, 'pilot@apex.com'); + fakeFirestore = FakeFirebaseFirestore(); + }); + + tearDown(() async { + await db.close(); + }); + + Map createFirestoreRideData({ + String ownerId = testUid, + String rideId = remoteRideId, + String bundleStatus = 'uploaded', + String? objectKey, + String sha256 = validSha256, + int size = validSize, + }) { + final key = objectKey ?? 'users/$ownerId/rides/$rideId/telemetry.zip'; + return { + 'id': rideId, + 'ownerId': ownerId, + 'telemetryBundlePath': key, + 'telemetryBundle': { + 'provider': 'cloudflare-r2', + 'objectKey': key, + 'status': bundleStatus, + 'bundleSizeBytes': size, + 'sha256': sha256, + }, + }; + } + + test( + '1. Confirmed: Firestore doc + matching R2 bundle returns confirmed status', + () async { + await fakeFirestore + .collection('users') + .doc(testUid) + .collection('rides') + .doc(remoteRideId) + .set(createFirestoreRideData()); + + final mockClient = http_testing.MockClient((request) async { + expect(request.url.path, equals('/r2/verify')); + expect(request.url.queryParameters['rideId'], equals(remoteRideId)); + expect( + request.headers['authorization'], + contains('valid_mock_jwt_token'), + ); + return http.Response( + jsonEncode({ + 'verified': true, + 'remoteRideId': remoteRideId, + 'objectKey': 'users/$testUid/rides/$remoteRideId/telemetry.zip', + 'size': validSize, + 'sha256': validSha256, + 'etag': '"mock_etag_123"', + }), + 200, + ); + }); + + final r2Service = R2UploadService(client: mockClient); + final provider = FirebaseSyncProvider( + db: db, + authService: authService, + firestore: fakeFirestore, + r2UploadService: r2Service, + ); + + final result = await provider.verifyCloudConfirmation(remoteRideId); + expect(result.status, equals(CloudConfirmationStatus.confirmed)); + expect(result.isConfirmed, isTrue); + expect(result.sha256, equals(validSha256)); + expect(result.sizeBytes, equals(validSize)); + expect(await provider.isRideConfirmedInCloud(remoteRideId), isTrue); + }, + ); + + test( + '2. Missing: Firestore doc exists but R2 bundle is missing (404) returns missing', + () async { + await fakeFirestore + .collection('users') + .doc(testUid) + .collection('rides') + .doc(remoteRideId) + .set(createFirestoreRideData()); + + final mockClient = http_testing.MockClient((request) async { + return http.Response( + jsonEncode({ + 'error': 'Telemetry bundle not found in R2', + 'code': 'OBJECT_NOT_FOUND', + }), + 404, + ); + }); + + final r2Service = R2UploadService(client: mockClient); + final provider = FirebaseSyncProvider( + db: db, + authService: authService, + firestore: fakeFirestore, + r2UploadService: r2Service, + ); + + final result = await provider.verifyCloudConfirmation(remoteRideId); + expect(result.status, equals(CloudConfirmationStatus.missing)); + expect(result.isMissing, isTrue); + expect(await provider.isRideConfirmedInCloud(remoteRideId), isFalse); + }, + ); + + test( + '3. Missing: Firestore doc does not exist returns missing without calling R2', + () async { + var r2Called = false; + final mockClient = http_testing.MockClient((request) async { + r2Called = true; + return http.Response('{}', 200); + }); + + final r2Service = R2UploadService(client: mockClient); + final provider = FirebaseSyncProvider( + db: db, + authService: authService, + firestore: fakeFirestore, + r2UploadService: r2Service, + ); + + final result = await provider.verifyCloudConfirmation(remoteRideId); + expect(result.status, equals(CloudConfirmationStatus.missing)); + expect( + r2Called, + isFalse, + reason: 'R2 must not be queried when Firestore metadata is absent', + ); + }, + ); + + test( + '4. Inconsistent: Checksum mismatch between Firestore and R2 returns inconsistent', + () async { + await fakeFirestore + .collection('users') + .doc(testUid) + .collection('rides') + .doc(remoteRideId) + .set(createFirestoreRideData(sha256: validSha256)); + + final mockClient = http_testing.MockClient((request) async { + return http.Response( + jsonEncode({ + 'verified': true, + 'remoteRideId': remoteRideId, + 'objectKey': 'users/$testUid/rides/$remoteRideId/telemetry.zip', + 'size': validSize, + 'sha256': 'different_corrupted_hash_9999', + }), + 200, + ); + }); + + final r2Service = R2UploadService(client: mockClient); + final provider = FirebaseSyncProvider( + db: db, + authService: authService, + firestore: fakeFirestore, + r2UploadService: r2Service, + ); + + final result = await provider.verifyCloudConfirmation(remoteRideId); + expect(result.status, equals(CloudConfirmationStatus.inconsistent)); + expect(result.isInconsistent, isTrue); + expect(await provider.isRideConfirmedInCloud(remoteRideId), isFalse); + }, + ); + + test( + '5. Inconsistent: Object size mismatch returns inconsistent', + () async { + await fakeFirestore + .collection('users') + .doc(testUid) + .collection('rides') + .doc(remoteRideId) + .set(createFirestoreRideData(size: 4096)); + + final mockClient = http_testing.MockClient((request) async { + return http.Response( + jsonEncode({ + 'verified': true, + 'remoteRideId': remoteRideId, + 'objectKey': 'users/$testUid/rides/$remoteRideId/telemetry.zip', + 'size': 99999, // Mismatched size + 'sha256': validSha256, + }), + 200, + ); + }); + + final r2Service = R2UploadService(client: mockClient); + final provider = FirebaseSyncProvider( + db: db, + authService: authService, + firestore: fakeFirestore, + r2UploadService: r2Service, + ); + + final result = await provider.verifyCloudConfirmation(remoteRideId); + expect(result.status, equals(CloudConfirmationStatus.inconsistent)); + }, + ); + + test( + '6. Inconsistent: Wrong remote ride identity inside doc metadata returns inconsistent', + () async { + await fakeFirestore + .collection('users') + .doc(testUid) + .collection('rides') + .doc(remoteRideId) + .set(createFirestoreRideData(rideId: 'different_ride_999')); + + final r2Service = R2UploadService( + client: http_testing.MockClient( + (_) async => http.Response('{}', 200), + ), + ); + final provider = FirebaseSyncProvider( + db: db, + authService: authService, + firestore: fakeFirestore, + r2UploadService: r2Service, + ); + + final result = await provider.verifyCloudConfirmation(remoteRideId); + expect(result.status, equals(CloudConfirmationStatus.inconsistent)); + }, + ); + + test( + '7. Unauthorized: Wrong owner inside doc returns unauthorized', + () async { + await fakeFirestore + .collection('users') + .doc(testUid) + .collection('rides') + .doc(remoteRideId) + .set(createFirestoreRideData(ownerId: 'different_owner_uid')); + + final r2Service = R2UploadService( + client: http_testing.MockClient( + (_) async => http.Response('{}', 200), + ), + ); + final provider = FirebaseSyncProvider( + db: db, + authService: authService, + firestore: fakeFirestore, + r2UploadService: r2Service, + ); + + final result = await provider.verifyCloudConfirmation(remoteRideId); + expect(result.status, equals(CloudConfirmationStatus.unauthorized)); + expect(result.isUnauthorized, isTrue); + }, + ); + + test( + '8. Unauthorized: R2 Worker HTTP 401/403 returns unauthorized', + () async { + await fakeFirestore + .collection('users') + .doc(testUid) + .collection('rides') + .doc(remoteRideId) + .set(createFirestoreRideData()); + + final mockClient = http_testing.MockClient((request) async { + return http.Response( + jsonEncode({'error': 'Unauthorized token'}), + 401, + ); + }); + + final r2Service = R2UploadService(client: mockClient); + final provider = FirebaseSyncProvider( + db: db, + authService: authService, + firestore: fakeFirestore, + r2UploadService: r2Service, + ); + + final result = await provider.verifyCloudConfirmation(remoteRideId); + expect(result.status, equals(CloudConfirmationStatus.unauthorized)); + }, + ); + + test('9. Rate Limited: HTTP 429 returns temporarilyUnverifiable', () async { + await fakeFirestore + .collection('users') + .doc(testUid) + .collection('rides') + .doc(remoteRideId) + .set(createFirestoreRideData()); + + final mockClient = http_testing.MockClient((request) async { + return http.Response('Rate limit exceeded', 429); + }); + + final r2Service = R2UploadService(client: mockClient); + final provider = FirebaseSyncProvider( + db: db, + authService: authService, + firestore: fakeFirestore, + r2UploadService: r2Service, + ); + + final result = await provider.verifyCloudConfirmation(remoteRideId); + expect( + result.status, + equals(CloudConfirmationStatus.temporarilyUnverifiable), + ); + expect(result.isTemporarilyUnverifiable, isTrue); + }); + + test( + '10. Server Error: HTTP 500/503 returns temporarilyUnverifiable', + () async { + await fakeFirestore + .collection('users') + .doc(testUid) + .collection('rides') + .doc(remoteRideId) + .set(createFirestoreRideData()); + + final mockClient = http_testing.MockClient((request) async { + return http.Response('Cloudflare backend error', 503); + }); + + final r2Service = R2UploadService(client: mockClient); + final provider = FirebaseSyncProvider( + db: db, + authService: authService, + firestore: fakeFirestore, + r2UploadService: r2Service, + ); + + final result = await provider.verifyCloudConfirmation(remoteRideId); + expect( + result.status, + equals(CloudConfirmationStatus.temporarilyUnverifiable), + ); + }, + ); + + test( + '11. Network Timeout: Timeout returns temporarilyUnverifiable', + () async { + await fakeFirestore + .collection('users') + .doc(testUid) + .collection('rides') + .doc(remoteRideId) + .set(createFirestoreRideData()); + + final mockClient = http_testing.MockClient((request) async { + await Future.delayed(const Duration(milliseconds: 100)); + return http.Response('{}', 200); + }); + + final r2Service = R2UploadService(client: mockClient); + + // We test R2 timeout directly + final r2Direct = await r2Service.verifyTelemetryBundle( + idToken: 'token', + remoteRideId: remoteRideId, + timeout: const Duration(milliseconds: 10), + ); + expect( + r2Direct.status, + equals(CloudConfirmationStatus.temporarilyUnverifiable), + ); + }, + ); + + test( + '12. Worker Endpoint Undeployed (404 Endpoint not found) returns temporarilyUnverifiable', + () async { + await fakeFirestore + .collection('users') + .doc(testUid) + .collection('rides') + .doc(remoteRideId) + .set(createFirestoreRideData()); + + final mockClient = http_testing.MockClient((request) async { + // Current live worker response when /r2/verify is not yet deployed + return http.Response( + jsonEncode({'error': 'Endpoint not found'}), + 404, + ); + }); + + final r2Service = R2UploadService(client: mockClient); + final provider = FirebaseSyncProvider( + db: db, + authService: authService, + firestore: fakeFirestore, + r2UploadService: r2Service, + ); + + final result = await provider.verifyCloudConfirmation(remoteRideId); + expect( + result.status, + equals(CloudConfirmationStatus.temporarilyUnverifiable), + ); + expect(result.isConfirmed, isFalse); + }, + ); + + test( + '13. Legacy bundles without optional fields confirm cleanly when R2 verifies', + () async { + // Legacy document without sha256 or bundleSizeBytes in metadata + await fakeFirestore + .collection('users') + .doc(testUid) + .collection('rides') + .doc(remoteRideId) + .set({ + 'id': remoteRideId, + 'ownerId': testUid, + 'telemetryBundle': { + 'provider': 'cloudflare-r2', + 'objectKey': 'users/$testUid/rides/$remoteRideId/telemetry.zip', + 'status': 'uploaded', + }, + }); + + final mockClient = http_testing.MockClient((request) async { + return http.Response( + jsonEncode({ + 'verified': true, + 'remoteRideId': remoteRideId, + 'objectKey': 'users/$testUid/rides/$remoteRideId/telemetry.zip', + 'size': 1024, + 'sha256': 'legacy_hash', + }), + 200, + ); + }); + + final r2Service = R2UploadService(client: mockClient); + final provider = FirebaseSyncProvider( + db: db, + authService: authService, + firestore: fakeFirestore, + r2UploadService: r2Service, + ); + + final result = await provider.verifyCloudConfirmation(remoteRideId); + expect(result.status, equals(CloudConfirmationStatus.legacyUnverified)); + expect(result.isLegacyUnverified, isTrue); + }, + ); + + test( + '14. Edge Routing: Non-JSON HTML 404 from proxy returns temporarilyUnverifiable instead of missing', + () async { + await fakeFirestore + .collection('users') + .doc(testUid) + .collection('rides') + .doc(remoteRideId) + .set(createFirestoreRideData()); + + final mockClient = http_testing.MockClient((request) async { + return http.Response( + '404 Not Found

404 Not Found

cloudflare', + 404, + ); + }); + + final r2Service = R2UploadService(client: mockClient); + final provider = FirebaseSyncProvider( + db: db, + authService: authService, + firestore: fakeFirestore, + r2UploadService: r2Service, + ); + + final result = await provider.verifyCloudConfirmation(remoteRideId); + expect( + result.status, + equals(CloudConfirmationStatus.temporarilyUnverifiable), + ); + }, + ); + + test( + '15. uploadRide Protection: Aborts with permanentFailure when cloud confirmation is inconsistent (never overwrites object)', + () async { + final rideId = await db.startRide( + startTime: DateTime.utc(2026, 10, 12, 10, 0, 0), + ); + await db.endRide(rideId, const RidesCompanion()); + await db.claimRideForCloudOwner(rideId, testUid); + final actualRemoteRideId = await db.ensureCloudRideId(rideId); + + // Firestore document exists + await fakeFirestore + .collection('users') + .doc(testUid) + .collection('rides') + .doc(actualRemoteRideId) + .set(createFirestoreRideData(rideId: actualRemoteRideId)); + + var uploadAttempted = false; + final mockClient = http_testing.MockClient((request) async { + if (request.url.path == '/r2/verify') { + // Return inconsistent hash + return http.Response( + jsonEncode({ + 'verified': true, + 'remoteRideId': actualRemoteRideId, + 'objectKey': + 'users/$testUid/rides/$actualRemoteRideId/telemetry.zip', + 'size': validSize, + 'sha256': 'corrupted_hash_mismatch_123', + }), + 200, + ); + } + if (request.url.path == '/r2/upload') { + uploadAttempted = true; + return http.Response('{}', 200); + } + return http.Response('{}', 404); + }); + + final r2Service = R2UploadService(client: mockClient); + final provider = FirebaseSyncProvider( + db: db, + authService: authService, + firestore: fakeFirestore, + r2UploadService: r2Service, + ); + + final result = await provider.uploadRide( + SyncItem( + localRideId: rideId, + providerId: 'firebase', + createdAt: DateTime.now(), + ), + ); + + expect(result.success, isFalse); + expect(result.isRetryable, isFalse); + expect(result.errorMessage, contains('inconsistent')); + expect( + uploadAttempted, + isFalse, + reason: + 'Must NEVER attempt R2 upload when cloud data is inconsistent', + ); + }, + ); + + test( + '16. uploadRide Protection: Defers with temporaryFailure when cloud verification is temporarilyUnverifiable (no heavy upload)', + () async { + final rideId = await db.startRide( + startTime: DateTime.utc(2026, 10, 12, 10, 0, 0), + ); + await db.endRide(rideId, const RidesCompanion()); + await db.claimRideForCloudOwner(rideId, testUid); + final actualRemoteRideId = await db.ensureCloudRideId(rideId); + + await fakeFirestore + .collection('users') + .doc(testUid) + .collection('rides') + .doc(actualRemoteRideId) + .set(createFirestoreRideData(rideId: actualRemoteRideId)); + + var uploadAttempted = false; + final mockClient = http_testing.MockClient((request) async { + if (request.url.path == '/r2/verify') { + return http.Response('Rate limit exceeded', 429); + } + if (request.url.path == '/r2/upload') { + uploadAttempted = true; + return http.Response('{}', 200); + } + return http.Response('{}', 404); + }); + + final r2Service = R2UploadService(client: mockClient); + final provider = FirebaseSyncProvider( + db: db, + authService: authService, + firestore: fakeFirestore, + r2UploadService: r2Service, + ); + + final result = await provider.uploadRide( + SyncItem( + localRideId: rideId, + providerId: 'firebase', + createdAt: DateTime.now(), + ), + ); + + expect(result.success, isFalse); + expect(result.isRetryable, isTrue); + expect(result.errorMessage, contains('temporarily unavailable')); + expect( + uploadAttempted, + isFalse, + reason: 'Must NOT upload bundle when verification is rate limited', + ); + }, + ); + + test( + '17. uploadRide Protection: Returns authRequired when cloud verification is unauthorized', + () async { + final rideId = await db.startRide( + startTime: DateTime.utc(2026, 10, 12, 10, 0, 0), + ); + await db.endRide(rideId, const RidesCompanion()); + await db.claimRideForCloudOwner(rideId, testUid); + final actualRemoteRideId = await db.ensureCloudRideId(rideId); + + await fakeFirestore + .collection('users') + .doc(testUid) + .collection('rides') + .doc(actualRemoteRideId) + .set( + createFirestoreRideData( + ownerId: 'different_owner_uid', + rideId: actualRemoteRideId, + ), + ); + + var uploadAttempted = false; + final mockClient = http_testing.MockClient((request) async { + if (request.url.path == '/r2/upload') { + uploadAttempted = true; + } + return http.Response('{}', 200); + }); + + final r2Service = R2UploadService(client: mockClient); + final provider = FirebaseSyncProvider( + db: db, + authService: authService, + firestore: fakeFirestore, + r2UploadService: r2Service, + ); + + final result = await provider.uploadRide( + SyncItem( + localRideId: rideId, + providerId: 'firebase', + createdAt: DateTime.now(), + ), + ); + + expect(result.success, isFalse); + expect(result.requiresAuth, isTrue); + expect(uploadAttempted, isFalse); + }, + ); + + test( + '18. Modern bundle (syncVersion >= 3) missing required sha256 returns inconsistent', + () async { + await fakeFirestore + .collection('users') + .doc(testUid) + .collection('rides') + .doc(remoteRideId) + .set({ + 'id': remoteRideId, + 'ownerId': testUid, + 'syncVersion': 3, + 'bundleSizeBytes': 2048, + 'telemetryBundle': { + 'provider': 'cloudflare-r2', + 'objectKey': 'users/$testUid/rides/$remoteRideId/telemetry.zip', + 'status': 'uploaded', + 'bundleSizeBytes': 2048, + }, + }); + + final mockClient = http_testing.MockClient( + (request) async => http.Response('{}', 200), + ); + final r2Service = R2UploadService(client: mockClient); + final provider = FirebaseSyncProvider( + db: db, + authService: authService, + firestore: fakeFirestore, + r2UploadService: r2Service, + ); + + final result = await provider.verifyCloudConfirmation(remoteRideId); + expect(result.status, equals(CloudConfirmationStatus.inconsistent)); + expect(result.isInconsistent, isTrue); + expect(result.message, contains('missing required sha256')); + }, + ); + + test( + '19. Modern bundle (syncVersion >= 3) with non-positive bundleSizeBytes returns inconsistent', + () async { + await fakeFirestore + .collection('users') + .doc(testUid) + .collection('rides') + .doc(remoteRideId) + .set({ + 'id': remoteRideId, + 'ownerId': testUid, + 'syncVersion': 3, + 'bundleSizeBytes': 0, + 'sha256': validSha256, + 'telemetryBundle': { + 'provider': 'cloudflare-r2', + 'objectKey': 'users/$testUid/rides/$remoteRideId/telemetry.zip', + 'status': 'uploaded', + 'bundleSizeBytes': 0, + 'sha256': validSha256, + }, + }); + + final mockClient = http_testing.MockClient( + (request) async => http.Response('{}', 200), + ); + final r2Service = R2UploadService(client: mockClient); + final provider = FirebaseSyncProvider( + db: db, + authService: authService, + firestore: fakeFirestore, + r2UploadService: r2Service, + ); + + final result = await provider.verifyCloudConfirmation(remoteRideId); + expect(result.status, equals(CloudConfirmationStatus.inconsistent)); + expect(result.isInconsistent, isTrue); + expect(result.message, contains('invalid bundleSizeBytes')); + }, + ); + + test( + '20. R2 Worker response with invalid objectKey pattern returns inconsistent', + () async { + await fakeFirestore + .collection('users') + .doc(testUid) + .collection('rides') + .doc(remoteRideId) + .set(createFirestoreRideData()); + + final mockClient = http_testing.MockClient((request) async { + return http.Response( + jsonEncode({ + 'verified': true, + 'remoteRideId': remoteRideId, + 'objectKey': 'wrong_path/telemetry.zip', + 'size': validSize, + 'sha256': validSha256, + }), + 200, + ); + }); + + final r2Service = R2UploadService(client: mockClient); + final provider = FirebaseSyncProvider( + db: db, + authService: authService, + firestore: fakeFirestore, + r2UploadService: r2Service, + ); + + final result = await provider.verifyCloudConfirmation(remoteRideId); + expect(result.status, equals(CloudConfirmationStatus.inconsistent)); + expect(result.message, contains('objectKey')); + }, + ); + + test( + '21. R2 Worker response with non-positive size returns inconsistent', + () async { + await fakeFirestore + .collection('users') + .doc(testUid) + .collection('rides') + .doc(remoteRideId) + .set(createFirestoreRideData()); + + final mockClient = http_testing.MockClient((request) async { + return http.Response( + jsonEncode({ + 'verified': true, + 'remoteRideId': remoteRideId, + 'objectKey': 'users/$testUid/rides/$remoteRideId/telemetry.zip', + 'size': 0, + 'sha256': validSha256, + }), + 200, + ); + }); + + final r2Service = R2UploadService(client: mockClient); + final provider = FirebaseSyncProvider( + db: db, + authService: authService, + firestore: fakeFirestore, + r2UploadService: r2Service, + ); + + final result = await provider.verifyCloudConfirmation(remoteRideId); + expect(result.status, equals(CloudConfirmationStatus.inconsistent)); + expect(result.message, contains('size')); + }, + ); + + test( + '22. uploadRide Protection: Legacy unverified ride confirms without re-uploading bundle', + () async { + final rideId = await db.startRide( + startTime: DateTime.utc(2026, 10, 12, 10, 0, 0), + ); + await db.endRide(rideId, const RidesCompanion()); + await db.claimRideForCloudOwner(rideId, testUid); + final actualRemoteRideId = await db.ensureCloudRideId(rideId); + + // Legacy Firestore record without sha256 + await fakeFirestore + .collection('users') + .doc(testUid) + .collection('rides') + .doc(actualRemoteRideId) + .set({ + 'id': actualRemoteRideId, + 'ownerId': testUid, + 'telemetryBundle': { + 'provider': 'cloudflare-r2', + 'objectKey': + 'users/$testUid/rides/$actualRemoteRideId/telemetry.zip', + 'status': 'uploaded', + }, + }); + + var uploadAttempted = false; + final mockClient = http_testing.MockClient((request) async { + if (request.url.path == '/r2/verify') { + return http.Response( + jsonEncode({ + 'verified': true, + 'remoteRideId': actualRemoteRideId, + 'objectKey': + 'users/$testUid/rides/$actualRemoteRideId/telemetry.zip', + 'size': 1024, + 'sha256': 'legacy_hash', + }), + 200, + ); + } + if (request.url.path == '/r2/upload') { + uploadAttempted = true; + } + return http.Response('{}', 200); + }); + + final r2Service = R2UploadService(client: mockClient); + final provider = FirebaseSyncProvider( + db: db, + authService: authService, + firestore: fakeFirestore, + r2UploadService: r2Service, + ); + + final result = await provider.uploadRide( + SyncItem( + localRideId: rideId, + providerId: 'firebase', + createdAt: DateTime.now(), + ), + ); + + expect(result.success, isTrue); + expect(result.remoteId, equals(actualRemoteRideId)); + expect( + uploadAttempted, + isFalse, + reason: 'Must not re-upload existing historical bundle', + ); + }, + ); + + test( + '23. Expired Firebase token triggers forceRefresh and confirms on retry', + () async { + await fakeFirestore + .collection('users') + .doc(testUid) + .collection('rides') + .doc(remoteRideId) + .set(createFirestoreRideData()); + + authService.idTokenToReturn = 'expired_old_token'; + authService.refreshedIdToken = 'fresh_valid_jwt_token'; + + var callCount = 0; + final mockClient = http_testing.MockClient((request) async { + callCount++; + final authHeader = request.headers['Authorization']; + if (authHeader == 'Bearer expired_old_token') { + return http.Response( + jsonEncode({'error': 'Token expired', 'code': 'UNAUTHORIZED'}), + 401, + ); + } + if (authHeader == 'Bearer fresh_valid_jwt_token') { + return http.Response( + jsonEncode({ + 'verified': true, + 'remoteRideId': remoteRideId, + 'objectKey': 'users/$testUid/rides/$remoteRideId/telemetry.zip', + 'size': validSize, + 'sha256': validSha256, + }), + 200, + ); + } + return http.Response('{}', 403); + }); + + final r2Service = R2UploadService(client: mockClient); + final provider = FirebaseSyncProvider( + db: db, + authService: authService, + firestore: fakeFirestore, + r2UploadService: r2Service, + ); + + final result = await provider.verifyCloudConfirmation(remoteRideId); + expect(result.status, equals(CloudConfirmationStatus.confirmed)); + expect(result.isConfirmed, isTrue); + expect( + callCount, + equals(2), + reason: 'Should have retried once with refreshed token', + ); + }, + ); + + test( + '24. Worker returns 503 TEMPORARY_ERROR when Firestore temporarily fails (maps to temporarilyUnverifiable)', + () async { + await fakeFirestore + .collection('users') + .doc(testUid) + .collection('rides') + .doc(remoteRideId) + .set(createFirestoreRideData()); + + final mockClient = http_testing.MockClient((request) async { + return http.Response( + jsonEncode({ + 'error': 'Firestore temporary lookup error', + 'code': 'TEMPORARY_ERROR', + }), + 503, + ); + }); + + final r2Service = R2UploadService(client: mockClient); + final provider = FirebaseSyncProvider( + db: db, + authService: authService, + firestore: fakeFirestore, + r2UploadService: r2Service, + ); + + final result = await provider.verifyCloudConfirmation(remoteRideId); + expect( + result.status, + equals(CloudConfirmationStatus.temporarilyUnverifiable), + ); + expect(result.isConfirmed, isFalse); + }, + ); + }); +} diff --git a/test/completed_ride_sync_recovery_test.dart b/test/completed_ride_sync_recovery_test.dart new file mode 100644 index 0000000..6c256f3 --- /dev/null +++ b/test/completed_ride_sync_recovery_test.dart @@ -0,0 +1,890 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:drift/native.dart'; +import 'package:drift/drift.dart' as drift; +import 'package:motorcycle_dash/database/database.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/sync/sync_models.dart'; +import 'package:motorcycle_dash/services/sync/sync_provider.dart'; +import 'package:motorcycle_dash/services/sync/sync_outbox.dart'; +import 'package:motorcycle_dash/services/sync/sync_manager.dart'; + +class _TestAuthService implements AuthService { + AuthUser? _currentUser; + AuthState _authState = const AuthState.unauthenticated(); + + void setAuthenticatedUser(String uid, String email) { + _currentUser = AuthUser( + uid: uid, + email: email, + displayName: 'Test Rider', + isAnonymous: false, + ); + _authState = AuthState.authenticated(_currentUser!); + } + + void setGuestUser() { + _currentUser = const AuthUser( + uid: 'guest_uid_123', + email: null, + displayName: 'Guest Rider', + isAnonymous: true, + ); + _authState = AuthState.anonymous(_currentUser!); + } + + void setLoggedOut() { + _currentUser = null; + _authState = const AuthState.unauthenticated(); + } + + @override + AuthUser? get currentUser => _currentUser; + + @override + AuthState get currentState => _authState; + + @override + Stream get authStateChanges => Stream.value(_authState); + + @override + bool get isConfigured => true; + + @override + bool get isEmailVerified => false; + + @override + Future createUserWithEmailAndPassword( + String email, + String password, { + String? displayName, + }) async {} + + @override + Future deleteCurrentUser() async {} + + @override + void dispose() {} + + @override + Future ensureBackendIdentity() async {} + + @override + Future getIdToken([bool forceRefresh = false]) async => + 'fake_id_token'; + + @override + Future reauthenticate({String? password}) async {} + + @override + Future reloadCurrentUser() async {} + + @override + Future sendEmailVerification() async {} + + @override + Future sendPasswordResetEmail(String email) async {} + + @override + Future signInAnonymously() async => setGuestUser(); + + @override + Future signInWithEmailAndPassword( + String email, + String password, + ) async => setAuthenticatedUser('uid_123', email); + + @override + Future signInWithGoogle() async => + setAuthenticatedUser('uid_google', 'google@example.com'); + + @override + Future signOut() async => setLoggedOut(); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('v1.1 Completed-Ride Sync Outbox Recovery Tests', () { + late SharedPreferences prefs; + late AppDatabase db; + late _TestAuthService authService; + late FakeSyncProvider fakeProvider; + + setUp(() async { + SharedPreferences.setMockInitialValues({}); + prefs = await SharedPreferences.getInstance(); + db = AppDatabase.forTesting(NativeDatabase.memory()); + authService = _TestAuthService(); + fakeProvider = FakeSyncProvider(providerId: 'firebase'); + }); + + tearDown(() async { + await db.close(); + }); + + test( + '1. Crash Window Recovery: Completed ride missing from outbox is recovered and uploaded', + () async { + authService.setAuthenticatedUser('rider_alice', 'alice@example.com'); + + // Simulate a completed ride committed to SQLite during a previous session + final rideId = await db.startRide( + startTime: DateTime.utc(2026, 10, 12, 10, 0, 0), + motorcycleMake: 'Honda', + motorcycleModel: 'CBR600RR', + ); + await db.endRide(rideId, const RidesCompanion()); + + // Confirm outbox starts empty (app terminated before enqueue ran) + final outbox = SyncOutbox(prefs); + expect(outbox.count, equals(0)); + + // Startup initialization of SyncManager (testing step-by-step) + final manager = SyncManager( + outbox, + initialProviders: [fakeProvider], + defaultProviderId: 'firebase', + db: db, + authService: authService, + autoStart: false, + ); + + // Reconcile runs + final report = await manager.reconcileCompletedRides(); + + expect(report.discoveredCompletedRides, equals(1)); + expect(report.recoveredQueuedCount, equals(1)); + expect(report.alreadyTrackedCount, equals(0)); + expect(outbox.count, equals(1)); + + final item = outbox.getItem(rideId, 'firebase'); + expect(item, isNotNull); + expect(item?.status, equals(SyncStatus.queued)); + + // Verify ride ownership was claimed for authenticated user + final ownerUid = await db.getCloudOwnerUid(rideId); + expect(ownerUid, equals('rider_alice')); + + // Process queue to complete upload + await manager.processQueue(); + expect( + outbox.getItem(rideId, 'firebase')?.status, + equals(SyncStatus.synced), + ); + expect(fakeProvider.uploadedItems.length, equals(1)); + }, + ); + + test( + '2. Idempotence: Repeated reconciliation does not create duplicate entries or uploads', + () async { + authService.setAuthenticatedUser('rider_alice', 'alice@example.com'); + + final rideId = await db.startRide( + startTime: DateTime.utc(2026, 10, 12, 11, 0, 0), + ); + await db.endRide(rideId, const RidesCompanion()); + + final outbox = SyncOutbox(prefs); + final manager = SyncManager( + outbox, + initialProviders: [fakeProvider], + defaultProviderId: 'firebase', + db: db, + authService: authService, + autoStart: false, + ); + + // First run reconciles and queues the ride + final report1 = await manager.reconcileCompletedRides(); + expect(report1.recoveredQueuedCount, equals(1)); + expect(outbox.count, equals(1)); + + // Process upload + await manager.processQueue(); + expect( + outbox.getItem(rideId, 'firebase')?.status, + equals(SyncStatus.synced), + ); + expect(fakeProvider.uploadedItems.length, equals(1)); + + // Second run reports ride as already tracked, zero duplicates + final report2 = await manager.reconcileCompletedRides(); + expect(report2.discoveredCompletedRides, equals(1)); + expect(report2.alreadyTrackedCount, equals(1)); + expect(report2.recoveredQueuedCount, equals(0)); + expect(report2.totalRecovered, equals(0)); + expect(outbox.count, equals(1)); + + // Running processQueue again does not re-upload + await manager.processQueue(); + expect(fakeProvider.uploadedItems.length, equals(1)); + }, + ); + + test( + '3. Guest State & Auth Transition: Recovers as authRequired then claims and syncs on sign-in', + () async { + // User is initially a guest and provider requires auth + authService.setGuestUser(); + fakeProvider.behavior = FakeSyncBehavior.authRequired; + + final rideId = await db.startRide( + startTime: DateTime.utc(2026, 10, 12, 12, 0, 0), + ); + await db.endRide(rideId, const RidesCompanion()); + + final outbox = SyncOutbox(prefs); + final manager = SyncManager( + outbox, + initialProviders: [fakeProvider], + defaultProviderId: 'firebase', + db: db, + authService: authService, + autoStart: false, + ); + + // Reconcile as guest -> authRequired + final report = await manager.reconcileCompletedRides(); + expect(report.recoveredAuthRequiredCount, equals(1)); + expect( + outbox.getItem(rideId, 'firebase')?.status, + equals(SyncStatus.authRequired), + ); + + // Ownership remains unclaimed + expect(await db.getCloudOwnerUid(rideId), isNull); + + // Guest now signs in with Google / Email + authService.setAuthenticatedUser('rider_bob', 'bob@example.com'); + fakeProvider.behavior = FakeSyncBehavior.success; + + // Invoking retryAuthRequired claims the ride and triggers queue upload + await manager.retryAuthRequired(); + + expect(await db.getCloudOwnerUid(rideId), equals('rider_bob')); + expect( + outbox.getItem(rideId, 'firebase')?.status, + equals(SyncStatus.synced), + ); + expect(fakeProvider.uploadedItems.length, equals(1)); + }, + ); + + test( + '4. Cloud Confirmed Skip: Confirmed ride marks synced without re-uploading bundle', + () async { + authService.setAuthenticatedUser( + 'rider_charlie', + 'charlie@example.com', + ); + + final rideId = await db.startRide( + startTime: DateTime.utc(2026, 10, 12, 13, 0, 0), + ); + await db.endRide(rideId, const RidesCompanion()); + final remoteRideId = await db.ensureCloudRideId(rideId); + + // Simulate that cloud backend already has this ride bundle uploaded + fakeProvider.confirmedRemoteRideIds.add(remoteRideId); + + final outbox = SyncOutbox(prefs); + final manager = SyncManager( + outbox, + initialProviders: [fakeProvider], + defaultProviderId: 'firebase', + db: db, + authService: authService, + ); + + final report = await manager.reconcileCompletedRides(); + expect(report.recoveredAlreadySyncedCount, equals(1)); + expect(report.recoveredQueuedCount, equals(0)); + + final item = outbox.getItem(rideId, 'firebase'); + expect(item?.status, equals(SyncStatus.synced)); + expect(item?.remoteId, equals(remoteRideId)); + + // Zero upload attempts because it was already confirmed + expect(fakeProvider.uploadedItems.isEmpty, isTrue); + }, + ); + + test( + '5. Foreign Account Isolation: Does not claim or upload rides belonging to another user', + () async { + authService.setAuthenticatedUser('rider_alice', 'alice@example.com'); + + // Create ride explicitly owned by rider_bob + final rideId = await db + .into(db.rides) + .insert( + RidesCompanion.insert( + cloudRideId: const drift.Value('ride_bob_123'), + cloudOwnerUid: const drift.Value('rider_bob'), + startTime: DateTime.utc(2026, 10, 12, 14, 0, 0), + endTime: drift.Value(DateTime.utc(2026, 10, 12, 15, 0, 0)), + ), + ); + + final outbox = SyncOutbox(prefs); + final manager = SyncManager( + outbox, + initialProviders: [fakeProvider], + defaultProviderId: 'firebase', + db: db, + authService: authService, + ); + + final report = await manager.reconcileCompletedRides(); + expect(report.foreignOwnerSkippedCount, equals(1)); + expect(report.totalRecovered, equals(0)); + expect(outbox.count, equals(0)); + + // Ownership in db remains unchanged + expect(await db.getCloudOwnerUid(rideId), equals('rider_bob')); + }, + ); + + test( + '6. In-Progress Isolation: Active rides without endTime are never recovered as completed', + () async { + authService.setAuthenticatedUser('rider_alice', 'alice@example.com'); + + // Start ride but do not end it (endTime is null) + final rideId = await db.startRide( + startTime: DateTime.utc(2026, 10, 12, 16, 0, 0), + ); + + final outbox = SyncOutbox(prefs); + final manager = SyncManager( + outbox, + initialProviders: [fakeProvider], + defaultProviderId: 'firebase', + db: db, + authService: authService, + ); + + final report = await manager.reconcileCompletedRides(); + expect(report.discoveredCompletedRides, equals(0)); + expect(report.totalRecovered, equals(0)); + expect(outbox.getItem(rideId, 'firebase'), isNull); + }, + ); + + test( + '7. Inconsistent Cloud Confirmation: Preserves local data and reports inconsistent skipped without overwriting', + () async { + authService.setAuthenticatedUser('rider_alice', 'alice@example.com'); + + final rideId = await db.startRide( + startTime: DateTime.utc(2026, 10, 12, 17, 0, 0), + ); + await db.endRide(rideId, const RidesCompanion()); + final remoteRideId = await db.ensureCloudRideId(rideId); + + fakeProvider.confirmationResults[remoteRideId] = + CloudConfirmationResult.inconsistent( + remoteRideId: remoteRideId, + message: 'SHA-256 mismatch detected in storage', + ); + + final outbox = SyncOutbox(prefs); + final manager = SyncManager( + outbox, + initialProviders: [fakeProvider], + defaultProviderId: 'firebase', + db: db, + authService: authService, + ); + + final report = await manager.reconcileCompletedRides(); + expect(report.inconsistentSkippedCount, equals(1)); + expect(report.recoveredAlreadySyncedCount, equals(0)); + + final item = outbox.getItem(rideId, 'firebase'); + expect(item, isNotNull); + expect(item?.status, equals(SyncStatus.failed)); + expect(item?.lastError, contains('inconsistent')); + + // Local ride remains safe in SQLite + final localRide = await (db.select( + db.rides, + )..where((r) => r.id.equals(rideId))).getSingle(); + expect(localRide.cloudRideId, equals(remoteRideId)); + }, + ); + + test( + '8. Temporarily Unverifiable State: Leaves ride in retryWait with backoff without marking permanently missing', + () async { + authService.setAuthenticatedUser('rider_alice', 'alice@example.com'); + + final rideId = await db.startRide( + startTime: DateTime.utc(2026, 10, 12, 18, 0, 0), + ); + await db.endRide(rideId, const RidesCompanion()); + final remoteRideId = await db.ensureCloudRideId(rideId); + + fakeProvider.confirmationResults[remoteRideId] = + CloudConfirmationResult.temporarilyUnverifiable( + remoteRideId: remoteRideId, + message: 'HTTP 503 backend gateway timeout', + ); + + final outbox = SyncOutbox(prefs); + final manager = SyncManager( + outbox, + initialProviders: [fakeProvider], + defaultProviderId: 'firebase', + db: db, + authService: authService, + ); + + final report = await manager.reconcileCompletedRides(); + expect(report.unverifiablePendingCount, equals(1)); + expect(report.recoveredAlreadySyncedCount, equals(0)); + + final item = outbox.getItem(rideId, 'firebase'); + expect(item, isNotNull); + expect(item?.status, equals(SyncStatus.retryWait)); + expect(item?.nextRetryAt, isNotNull); + expect(item?.lastError, contains('temporarily unverifiable')); + }, + ); + + test( + '9. Pre-Upload Ownership Protection: processQueue cancels foreign-owned outbox item before calling provider', + () async { + authService.setAuthenticatedUser('rider_alice', 'alice@example.com'); + + // Create ride owned by rider_bob + final rideId = await db + .into(db.rides) + .insert( + RidesCompanion.insert( + cloudRideId: const drift.Value('ride_bob_456'), + cloudOwnerUid: const drift.Value('rider_bob'), + startTime: DateTime.utc(2026, 10, 12, 19, 0, 0), + endTime: drift.Value(DateTime.utc(2026, 10, 12, 20, 0, 0)), + ), + ); + + // Force an outbox item into the queue (e.g., from stale shared storage) + final outbox = SyncOutbox(prefs); + await outbox.update( + SyncItem( + localRideId: rideId, + providerId: 'firebase', + status: SyncStatus.queued, + createdAt: DateTime.now(), + ), + ); + + final manager = SyncManager( + outbox, + initialProviders: [fakeProvider], + defaultProviderId: 'firebase', + db: db, + authService: authService, + autoStart: false, + ); + + await manager.processQueue(); + + final item = outbox.getItem(rideId, 'firebase'); + expect(item?.status, equals(SyncStatus.cancelled)); + expect(item?.lastError, contains('belongs to another account')); + expect( + fakeProvider.uploadedItems.isEmpty, + isTrue, + reason: 'Must not call provider upload for foreign ride', + ); + }, + ); + + test( + '10. Mid-flight Auth Change Protection: Reverts upload to authRequired if user logs out mid-flight', + () async { + authService.setAuthenticatedUser('rider_alice', 'alice@example.com'); + + final rideId = await db.startRide( + startTime: DateTime.utc(2026, 10, 12, 21, 0, 0), + ); + await db.endRide(rideId, const RidesCompanion()); + await db.claimRideForCloudOwner(rideId, 'rider_alice'); + + final outbox = SyncOutbox(prefs); + await outbox.update( + SyncItem( + localRideId: rideId, + providerId: 'firebase', + status: SyncStatus.queued, + createdAt: DateTime.now(), + ), + ); + + // Provider simulates delay and during delay user logs out + fakeProvider.delayMs = 50; + + final manager = SyncManager( + outbox, + initialProviders: [fakeProvider], + defaultProviderId: 'firebase', + db: db, + authService: authService, + autoStart: false, + ); + + // Start processQueue + final uploadFuture = manager.processQueue(); + + // Mid-flight logout + await Future.delayed(const Duration(milliseconds: 10)); + authService.setLoggedOut(); + + await uploadFuture; + + final item = outbox.getItem(rideId, 'firebase'); + expect(item?.status, equals(SyncStatus.authRequired)); + expect( + item?.lastError, + contains('User changed or logged out during upload'), + ); + }, + ); + + test( + '11. Concurrency Protection: Simultaneous reconcileCompletedRides join the same future', + () async { + authService.setAuthenticatedUser('rider_alice', 'alice@example.com'); + + final rideId = await db.startRide( + startTime: DateTime.utc(2026, 10, 12, 22, 0, 0), + ); + await db.endRide(rideId, const RidesCompanion()); + + final outbox = SyncOutbox(prefs); + final manager = SyncManager( + outbox, + initialProviders: [fakeProvider], + defaultProviderId: 'firebase', + db: db, + authService: authService, + autoStart: false, + ); + + final results = await Future.wait([ + manager.reconcileCompletedRides(), + manager.reconcileCompletedRides(), + ]); + + expect(results[0].discoveredCompletedRides, equals(1)); + expect(results[1].discoveredCompletedRides, equals(1)); + expect(outbox.count, equals(1)); + }, + ); + + test( + '12. Stale Persisted Outbox on retryAllPending: Foreign-owned items are cancelled on retry', + () async { + authService.setAuthenticatedUser('rider_alice', 'alice@example.com'); + + final rideId = await db + .into(db.rides) + .insert( + RidesCompanion.insert( + cloudRideId: const drift.Value('ride_bob_789'), + cloudOwnerUid: const drift.Value('rider_bob'), + startTime: DateTime.utc(2026, 10, 12, 23, 0, 0), + endTime: drift.Value(DateTime.utc(2026, 10, 13, 0, 0, 0)), + ), + ); + + final outbox = SyncOutbox(prefs); + await outbox.update( + SyncItem( + localRideId: rideId, + providerId: 'firebase', + status: SyncStatus.failed, + lastError: 'Previous failure', + createdAt: DateTime.now(), + ), + ); + + final manager = SyncManager( + outbox, + initialProviders: [fakeProvider], + defaultProviderId: 'firebase', + db: db, + authService: authService, + autoStart: false, + ); + + await manager.retryAllPending(); + + final item = outbox.getItem(rideId, 'firebase'); + expect(item?.status, equals(SyncStatus.cancelled)); + expect(item?.lastError, contains('belongs to another account')); + expect(fakeProvider.uploadedItems.isEmpty, isTrue); + }, + ); + + test( + '13. In-Flight Cancellation Protection: cancelSync preserves cancelled status even after upload completes', + () async { + authService.setAuthenticatedUser('rider_alice', 'alice@example.com'); + + final rideId = await db.startRide( + startTime: DateTime.utc(2026, 10, 12, 10, 0, 0), + ); + await db.endRide(rideId, const RidesCompanion()); + await db.claimRideForCloudOwner(rideId, 'rider_alice'); + + final outbox = SyncOutbox(prefs); + await outbox.update( + SyncItem( + localRideId: rideId, + providerId: 'firebase', + status: SyncStatus.queued, + createdAt: DateTime.now(), + ), + ); + + // Simulate provider with 50ms upload latency + fakeProvider.delayMs = 50; + + final manager = SyncManager( + outbox, + initialProviders: [fakeProvider], + defaultProviderId: 'firebase', + db: db, + authService: authService, + autoStart: false, + ); + + // Start processing queue + final processFuture = manager.processQueue(); + + // While upload is in flight, user cancels sync + await Future.delayed(const Duration(milliseconds: 10)); + await manager.cancelSync(rideId, 'firebase'); + + await processFuture; + + final item = outbox.getItem(rideId, 'firebase'); + expect( + item?.status, + equals(SyncStatus.cancelled), + reason: + 'Item must remain cancelled and not get overwritten with synced', + ); + }, + ); + + test( + '14. Default Active Provider: Selects defaultProviderId or first initial provider, defaulting to fake for bare test manager', + () async { + final outbox = SyncOutbox(prefs); + final managerExplicit = SyncManager( + outbox, + defaultProviderId: 'firebase', + autoStart: false, + ); + expect(managerExplicit.activeProviderId, equals('firebase')); + + final customProvider = FakeSyncProvider(providerId: 'custom_gateway'); + final managerWithProviders = SyncManager( + outbox, + initialProviders: [customProvider], + autoStart: false, + ); + expect(managerWithProviders.activeProviderId, equals('custom_gateway')); + + final managerNoProviders = SyncManager(outbox, autoStart: false); + expect(managerNoProviders.activeProviderId, equals('fake')); + }, + ); + + test( + '15. Immediate Enqueue Ownership Protection: enqueueRide cancels foreign-owned rides immediately', + () async { + authService.setAuthenticatedUser('rider_alice', 'alice@example.com'); + + final rideId = await db + .into(db.rides) + .insert( + RidesCompanion.insert( + cloudRideId: const drift.Value('ride_bob_999'), + cloudOwnerUid: const drift.Value('rider_bob'), + startTime: DateTime.utc(2026, 10, 12, 11, 0, 0), + endTime: drift.Value(DateTime.utc(2026, 10, 12, 12, 0, 0)), + ), + ); + + final outbox = SyncOutbox(prefs); + final manager = SyncManager( + outbox, + initialProviders: [fakeProvider], + defaultProviderId: 'firebase', + db: db, + authService: authService, + autoStart: false, + ); + + final item = await manager.enqueueRide(rideId, autoProcess: false); + expect(item.status, equals(SyncStatus.cancelled)); + expect(item.lastError, contains('belongs to another account')); + expect( + outbox.getItem(rideId, 'firebase')?.status, + equals(SyncStatus.cancelled), + ); + }, + ); + + test( + '16. Failed Cloud Verification followed by Successful Retry: Recovers to synced without re-upload', + () async { + authService.setAuthenticatedUser('rider_alice', 'alice@example.com'); + + final rideId = await db.startRide( + startTime: DateTime.utc(2026, 10, 12, 13, 0, 0), + ); + await db.endRide(rideId, const RidesCompanion()); + final remoteRideId = await db.ensureCloudRideId(rideId); + + // Step 1: Initial reconciliation fails with temporary verification error (e.g. HTTP 503) + fakeProvider.confirmationResults[remoteRideId] = + CloudConfirmationResult.temporarilyUnverifiable( + remoteRideId: remoteRideId, + message: 'HTTP 503 gateway timeout', + ); + + final outbox = SyncOutbox(prefs); + final manager = SyncManager( + outbox, + initialProviders: [fakeProvider], + defaultProviderId: 'firebase', + db: db, + authService: authService, + autoStart: false, + ); + + final report1 = await manager.reconcileCompletedRides(); + expect(report1.unverifiablePendingCount, equals(1)); + expect( + outbox.getItem(rideId, 'firebase')?.status, + equals(SyncStatus.retryWait), + ); + + // Step 2: Backend recovers and verifies the bundle in R2 + fakeProvider.confirmationResults[remoteRideId] = + CloudConfirmationResult.confirmed(remoteRideId: remoteRideId); + fakeProvider.confirmedRemoteRideIds.add(remoteRideId); + + // Process queue on retry + await manager.retryAllPending(); + + expect( + outbox.getItem(rideId, 'firebase')?.status, + equals(SyncStatus.synced), + ); + expect( + fakeProvider.uploadedItems.isEmpty, + isTrue, + reason: + 'Must confirm and mark synced without re-uploading the bundle', + ); + }, + ); + + test( + '17. Legacy Unverified Cloud Confirmation: Recovers as synced without re-upload and tracks count', + () async { + authService.setAuthenticatedUser('rider_alice', 'alice@example.com'); + + final rideId = await db.startRide( + startTime: DateTime.utc(2026, 10, 12, 14, 0, 0), + ); + await db.endRide(rideId, const RidesCompanion()); + final remoteRideId = await db.ensureCloudRideId(rideId); + + fakeProvider.confirmationResults[remoteRideId] = + CloudConfirmationResult.legacyUnverified( + remoteRideId: remoteRideId, + ownerUid: 'rider_alice', + message: 'Historical bundle without checksum', + ); + + final outbox = SyncOutbox(prefs); + final manager = SyncManager( + outbox, + initialProviders: [fakeProvider], + defaultProviderId: 'firebase', + db: db, + authService: authService, + autoStart: false, + ); + + final report = await manager.reconcileCompletedRides(); + + expect(report.recoveredLegacyUnverifiedCount, equals(1)); + expect(report.recoveredAlreadySyncedCount, equals(1)); + expect(report.totalRecovered, equals(1)); + final item = outbox.getItem(rideId, 'firebase'); + expect(item?.status, equals(SyncStatus.synced)); + expect(item?.lastError, contains('without modern checksum')); + expect( + fakeProvider.uploadedItems.isEmpty, + isTrue, + reason: 'Must never re-upload existing legacy unverified bundle', + ); + }, + ); + + test( + '18. Reconciliation Never Claims Unauthorized Rides: cloudOwnerUid remains null when verification returns unauthorized', + () async { + authService.setAuthenticatedUser('rider_eve', 'eve@example.com'); + + final rideId = await db.startRide( + startTime: DateTime.utc(2026, 10, 12, 15, 0, 0), + ); + await db.endRide(rideId, const RidesCompanion()); + final remoteRideId = await db.ensureCloudRideId(rideId); + + // Verification returns unauthorized (foreign-owned or permission denied) + fakeProvider.confirmationResults[remoteRideId] = + CloudConfirmationResult.unauthorized( + remoteRideId: remoteRideId, + message: 'Foreign owner in cloud metadata', + ); + + final outbox = SyncOutbox(prefs); + final manager = SyncManager( + outbox, + initialProviders: [fakeProvider], + defaultProviderId: 'firebase', + db: db, + authService: authService, + autoStart: false, + ); + + final report = await manager.reconcileCompletedRides(); + + // Must NOT claim ownership for rider_eve + final localOwner = await db.getCloudOwnerUid(rideId); + expect( + localOwner, + isNull, + reason: 'Must NEVER claim an unauthorized ride', + ); + expect(report.foreignOwnerSkippedCount, equals(1)); + expect(report.errors.isNotEmpty, isTrue); + }, + ); + }); +} diff --git a/test/disposable_backup_recovery_verification_test.dart b/test/disposable_backup_recovery_verification_test.dart new file mode 100644 index 0000000..f52089b --- /dev/null +++ b/test/disposable_backup_recovery_verification_test.dart @@ -0,0 +1,194 @@ +import 'dart:io'; +import 'package:crypto/crypto.dart'; +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:motorcycle_dash/database/database.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/sync/sync_manager.dart'; +import 'package:motorcycle_dash/services/sync/sync_outbox.dart'; +import 'package:motorcycle_dash/services/sync/sync_provider.dart'; + +class _FakeAuthService implements AuthService { + final AuthUser _user; + + _FakeAuthService([String uid = 'pilot_pixel_8a']) + : _user = AuthUser( + uid: uid, + email: '$uid@example.com', + displayName: 'Pixel Pilot', + isAnonymous: false, + ); + + @override + AuthUser? get currentUser => _user; + @override + AuthState get currentState => AuthState.authenticated(_user); + @override + Stream get authStateChanges => Stream.value(currentState); + @override + bool get isConfigured => true; + @override + bool get isEmailVerified => true; + + @override + Future createUserWithEmailAndPassword(String email, String password, {String? displayName}) async {} + @override + Future deleteCurrentUser() async {} + @override + void dispose() {} + @override + Future ensureBackendIdentity() async {} + @override + Future getIdToken([bool forceRefresh = false]) async => 'fake_token'; + @override + Future reauthenticate({String? password}) async {} + @override + Future reloadCurrentUser() async {} + @override + Future sendEmailVerification() async {} + @override + Future sendPasswordResetEmail(String email) async {} + @override + Future signInAnonymously() async {} + @override + Future signInWithEmailAndPassword(String email, String password) async {} + @override + Future signInWithGoogle() async {} + @override + Future signOut() async {} +} + +File? _findBackupFile() { + final candidates = [ + r'C:\Users\Bill\Desktop\flutter-app\output\master-consolidation-20261012\backup-071303\motorsignal.sqlite', + 'output/master-consolidation-20261012/backup-071303/motorsignal.sqlite', + '../backup-071303/motorsignal.sqlite', + '../../output/master-consolidation-20261012/backup-071303/motorsignal.sqlite', + ]; + for (final path in candidates) { + final file = File(path); + if (file.existsSync()) { + return file; + } + } + return null; +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + final originalFile = _findBackupFile(); + + test( + 'Phase 6 — Disposable Backup Verification & Outbox Recovery Gate', + () async { + expect(originalFile, isNotNull, reason: 'Original backup file must exist when test is executed'); + final file = originalFile!; + + // 1. Verify original SHA-256 before copy + final originalBytes = await file.readAsBytes(); + final originalHash = sha256.convert(originalBytes).toString(); + const expectedHash = '7125915195cdcf1de2d351426d8a991789904e7c3befd12af6f0d0f0b2416122'; + expect(originalHash, equals(expectedHash), reason: 'Original backup must match authoritative SHA-256'); + + // 2. Create disposable copy in scratch directory + final scratchDir = Directory( + '${Directory.systemTemp.path}/motorsignal_backup_test_${DateTime.now().millisecondsSinceEpoch}', + ); + if (!scratchDir.existsSync()) { + scratchDir.createSync(recursive: true); + } + final disposableFile = File('${scratchDir.path}/disposable_motorsignal.sqlite'); + if (disposableFile.existsSync()) { + disposableFile.deleteSync(); + } + await file.copy(disposableFile.path); + expect(disposableFile.existsSync(), isTrue); + + // 3. Open disposable copy via Drift / SQLite + final db = AppDatabase.forTesting(NativeDatabase(disposableFile)); + + try { + // Integrity check + final integrityRows = await db.customSelect('PRAGMA integrity_check').get(); + final integrityResult = integrityRows.first.data['integrity_check']; + expect(integrityResult, equals('ok'), reason: 'SQLite integrity must be OK'); + + // Count rides and samples + final rideCountRows = await db.customSelect('SELECT COUNT(*) as count FROM rides').get(); + final totalRides = rideCountRows.first.data['count'] as int; + expect(totalRides, equals(22), reason: 'Must contain exactly 22 rides'); + + final completedRidesRows = await db.customSelect('SELECT COUNT(*) as count FROM rides WHERE end_time IS NOT NULL').get(); + final completedRides = completedRidesRows.first.data['count'] as int; + + final samplesRows = await db.customSelect('SELECT COUNT(*) as count FROM telemetry_samples').get(); + final totalSamples = samplesRows.first.data['count'] as int; + expect(totalSamples, equals(67798), reason: 'Must contain exactly 67,798 telemetry samples'); + + // 4. Test Foreign Account Isolation against real Pixel backup + // In the real backup, rides are owned by Pixel rider '4btn4W3iOLWgfloMBYYK5hWa1hY2' + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + final outbox = SyncOutbox(prefs); + + final fakeProvider = FakeSyncProvider(providerId: 'firebase'); + final foreignAuth = _FakeAuthService('foreign_pilot_123'); + + final foreignSyncManager = SyncManager( + outbox, + initialProviders: [fakeProvider], + defaultProviderId: 'firebase', + db: db, + authService: foreignAuth, + autoStart: false, + ); + + final foreignReport = await foreignSyncManager.reconcileCompletedRides(); + expect(foreignReport.errorCount, equals(0)); + expect(foreignReport.discoveredCompletedRides, equals(completedRides)); + expect(foreignReport.foreignOwnerSkippedCount, equals(completedRides), reason: 'Foreign account must NEVER claim or touch real Pixel rides'); + expect(foreignReport.totalRecovered, equals(0)); + expect(outbox.count, equals(0)); + + // 5. Test Owner Recovery against disposable backup + final ownerAuth = _FakeAuthService('4btn4W3iOLWgfloMBYYK5hWa1hY2'); + final ownerSyncManager = SyncManager( + outbox, + initialProviders: [fakeProvider], + defaultProviderId: 'firebase', + db: db, + authService: ownerAuth, + autoStart: false, + ); + + final ownerReport = await ownerSyncManager.reconcileCompletedRides(); + expect(ownerReport.errorCount, equals(0)); + expect(ownerReport.discoveredCompletedRides, equals(completedRides)); + expect(ownerReport.recoveredQueuedCount, equals(completedRides)); + expect(ownerReport.totalRecovered, equals(completedRides)); + expect(outbox.count, equals(completedRides)); + + // 6. Test repeated reconciliation idempotence as owner + final report2 = await ownerSyncManager.reconcileCompletedRides(); + expect(report2.totalRecovered, equals(0)); + expect(report2.alreadyTrackedCount, equals(completedRides)); + expect(outbox.count, equals(completedRides)); + + } finally { + await db.close(); + } + + // 6. Verify original SHA-256 after operations to prove zero mutation + final postBytes = await file.readAsBytes(); + final postHash = sha256.convert(postBytes).toString(); + expect(postHash, equals(expectedHash), reason: 'Original backup MUST remain 100% UNTOUCHED'); + }, + skip: originalFile == null + ? 'Real Pixel backup not present in this environment (e.g. CI runner)' + : null, + ); +} diff --git a/test/isolated_sync_integration_fixture_test.dart b/test/isolated_sync_integration_fixture_test.dart index 760f600..44223ee 100644 --- a/test/isolated_sync_integration_fixture_test.dart +++ b/test/isolated_sync_integration_fixture_test.dart @@ -104,6 +104,13 @@ class _IsolatedContractSyncProvider implements SyncProvider { return user == null || user.isAnonymous; } + @override + Future isRideConfirmedInCloud(String remoteRideId) async => false; + + @override + Future verifyCloudConfirmation(String remoteRideId) async => + CloudConfirmationResult.missing(remoteRideId: remoteRideId); + @override Future uploadRide(SyncItem item) async { if (!networkAvailable) { diff --git a/test/offline_first_sync_contract_test.dart b/test/offline_first_sync_contract_test.dart index 3141d3d..74248f4 100644 --- a/test/offline_first_sync_contract_test.dart +++ b/test/offline_first_sync_contract_test.dart @@ -20,6 +20,13 @@ class _CountingProvider implements SyncProvider { @override Future requiresAuthentication() async => result == _Result.authRequired; + @override + Future isRideConfirmedInCloud(String remoteRideId) async => false; + + @override + Future verifyCloudConfirmation(String remoteRideId) async => + CloudConfirmationResult.missing(remoteRideId: remoteRideId); + @override Future uploadRide(SyncItem item) async { uploadCount++; diff --git a/test/r2_firestore_retry_hardening_test.dart b/test/r2_firestore_retry_hardening_test.dart index 6a99cd1..c5c2bd2 100644 --- a/test/r2_firestore_retry_hardening_test.dart +++ b/test/r2_firestore_retry_hardening_test.dart @@ -31,6 +31,13 @@ class _R2ThenFirestoreProvider implements SyncProvider { @override Future requiresAuthentication() async => false; + @override + Future isRideConfirmedInCloud(String remoteRideId) async => false; + + @override + Future verifyCloudConfirmation(String remoteRideId) async => + CloudConfirmationResult.missing(remoteRideId: remoteRideId); + @override Future uploadRide(SyncItem item) async { attempts++; diff --git a/test/sync_persistence_validation_test.dart b/test/sync_persistence_validation_test.dart index 178f22f..4372913 100644 --- a/test/sync_persistence_validation_test.dart +++ b/test/sync_persistence_validation_test.dart @@ -21,6 +21,13 @@ class _ValidationProvider implements SyncProvider { @override Future requiresAuthentication() async => !authenticated; + @override + Future isRideConfirmedInCloud(String remoteRideId) async => false; + + @override + Future verifyCloudConfirmation(String remoteRideId) async => + CloudConfirmationResult.missing(remoteRideId: remoteRideId); + @override Future uploadRide(SyncItem item) async { uploadCount++;