diff --git a/cloudflare/motorsignal-r2-worker/src/index.ts b/cloudflare/motorsignal-r2-worker/src/index.ts index 263ea68..e79051b 100644 --- a/cloudflare/motorsignal-r2-worker/src/index.ts +++ b/cloudflare/motorsignal-r2-worker/src/index.ts @@ -146,10 +146,76 @@ export default { headers.set('X-MotorSignal-Ride-Id', remoteRideId); const sha256 = object.customMetadata?.sha256; if (sha256) headers.set('X-MotorSignal-Sha256', sha256); + headers.set('X-MotorSignal-Size', String(object.size)); for (const [k, v] of Object.entries(CORS_HEADERS)) headers.set(k, v); return new Response(object.body, { status: 200, headers }); } + if (request.method === 'HEAD' && path === '/r2/download') { + const remoteRideId = url.searchParams.get('rideId'); + if (!remoteRideId || !isValidRideId(remoteRideId)) return jsonResponse({ error: 'Invalid rideId parameter' }, 400); + + const ride = await fetchOwnedRide(projectId, authenticatedUid, remoteRideId, idToken); + if (ride === 'unauthorized') return jsonResponse({ error: 'Ride ownership could not be authorized' }, 403); + if (ride === 'not-found') return jsonResponse({ error: 'Ride not found' }, 404); + if (ride === 'invalid') return jsonResponse({ error: 'Ride metadata is invalid' }, 409); + + const objectKey = `users/${authenticatedUid}/rides/${remoteRideId}/telemetry.zip`; + const object = await env.R2_BUCKET.head(objectKey); + if (!object) return jsonResponse({ error: 'Telemetry bundle not found in R2' }, 404); + + const headers = new Headers(); + object.writeHttpMetadata(headers); + headers.set('etag', object.httpEtag); + headers.set('Content-Disposition', `attachment; filename="${remoteRideId}_telemetry.zip"`); + headers.set('X-MotorSignal-Ride-Id', remoteRideId); + const sha256 = object.customMetadata?.sha256; + if (sha256) headers.set('X-MotorSignal-Sha256', sha256); + headers.set('X-MotorSignal-Size', String(object.size)); + for (const [k, v] of Object.entries(CORS_HEADERS)) headers.set(k, v); + return new Response(null, { status: 200, headers }); + } + + if ((request.method === 'GET' || request.method === 'HEAD') && path === '/r2/verify') { + const remoteRideId = url.searchParams.get('rideId'); + if (!remoteRideId || !isValidRideId(remoteRideId)) return jsonResponse({ error: 'Invalid rideId parameter' }, 400); + + const ride = await fetchOwnedRide(projectId, authenticatedUid, remoteRideId, idToken); + if (ride === 'unauthorized') return jsonResponse({ error: 'Ride ownership could not be authorized', code: 'UNAUTHORIZED' }, 403); + if (ride === 'not-found') return jsonResponse({ error: 'Ride not found', code: 'NOT_FOUND' }, 404); + if (ride === 'invalid') return jsonResponse({ error: 'Ride metadata is invalid', code: 'INVALID_METADATA' }, 409); + + const objectKey = `users/${authenticatedUid}/rides/${remoteRideId}/telemetry.zip`; + const object = await env.R2_BUCKET.head(objectKey); + if (!object) return jsonResponse({ error: 'Telemetry bundle not found in R2', code: 'OBJECT_NOT_FOUND' }, 404); + + const sha256 = object.customMetadata?.sha256; + const appVersion = object.customMetadata?.appVersion; + const uploadedAt = object.customMetadata?.uploadedAt; + + const headers = new Headers(); + headers.set('etag', object.httpEtag); + headers.set('X-MotorSignal-Ride-Id', remoteRideId); + if (sha256) headers.set('X-MotorSignal-Sha256', sha256); + headers.set('X-MotorSignal-Size', String(object.size)); + for (const [k, v] of Object.entries(CORS_HEADERS)) headers.set(k, v); + + if (request.method === 'HEAD') { + return new Response(null, { status: 200, headers }); + } + + return jsonResponse({ + verified: true, + remoteRideId, + objectKey, + size: object.size, + etag: object.httpEtag, + sha256, + appVersion, + uploadedAt, + }, 200); + } + return jsonResponse({ error: 'Endpoint not found' }, 404); } catch (err: unknown) { console.error('MotorSignal Worker exception', err); diff --git a/lib/services/sync/firebase_sync_provider.dart b/lib/services/sync/firebase_sync_provider.dart index 1bead10..0a82075 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 @@ -43,35 +50,209 @@ class FirebaseSyncProvider implements SyncProvider { @override Future isRideConfirmedInCloud(String remoteRideId) async { + final result = await verifyCloudConfirmation(remoteRideId); + return result.isConfirmed; + } + + @override + Future verifyCloudConfirmation( + String remoteRideId, + ) async { final user = _authService.currentUser; - if (user == null || user.isAnonymous) return false; - if (!await isAvailable()) return false; + 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 { - final doc = await _firestore! + doc = await _firestore! .collection('users') .doc(user.uid) .collection('rides') .doc(remoteRideId) .get(); - if (!doc.exists) return false; - final data = doc.data(); - if (data == null) return false; - final bundle = data['telemetryBundle']; - if (bundle is Map && bundle['status'] == 'uploaded') { - return true; + } 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 false; - } catch (_) { - return false; + 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 expectedSha256 = (bundle['sha256'] ?? data['sha256'])?.toString(); + final expectedSize = + (bundle['bundleSizeBytes'] ?? data['bundleSizeBytes']) as num?; + + // 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.', + ); + } + + final 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.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 @@ -79,7 +260,11 @@ 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.', + ); + } // Refresh the Firebase ID token before a manual upload. This prevents a // recently completed sign-in from being rejected because the cached token @@ -88,11 +273,21 @@ 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.'); + 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. @@ -102,8 +297,14 @@ class FirebaseSyncProvider implements SyncProvider { } 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( @@ -119,7 +320,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, @@ -132,15 +335,34 @@ 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 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 firestorePayload = { 'id': remoteRideId, @@ -166,12 +388,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, @@ -183,9 +430,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 0e299db..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'; @@ -26,12 +23,18 @@ class GitHubSyncProvider implements SyncProvider { @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..21f7131 100644 --- a/lib/services/sync/r2_upload_service.dart +++ b/lib/services/sync/r2_upload_service.dart @@ -2,6 +2,84 @@ 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.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 +90,332 @@ 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. + 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 respSha256 = data['sha256']?.toString(); + if (expectedSha256 != null && + expectedSha256.trim().isNotEmpty && + respSha256 != null && + respSha256.trim().isNotEmpty) { + if (expectedSha256.trim().toLowerCase() != + respSha256.trim().toLowerCase()) { + return R2VerificationResponse.inconsistent( + 'SHA-256 checksum mismatch: expected $expectedSha256 but got $respSha256.', + statusCode: 422, + ); + } + } + final respSize = (data['size'] as num?)?.toInt(); + if (expectedSize != null && + expectedSize > 0 && + respSize != null && + respSize > 0) { + if (expectedSize != respSize) { + return R2VerificationResponse.inconsistent( + 'Size mismatch: expected $expectedSize bytes but got $respSize bytes.', + statusCode: 422, + ); + } + } + return R2VerificationResponse.confirmed( + R2VerificationMetadata( + verified: true, + remoteRideId: remoteRideId, + objectKey: objectKey, + size: respSize, + sha256: respSha256, + etag: data['etag']?.toString(), + ), + 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. Telemetry bundle or ride not found -> missing + try { + final decoded = jsonDecode(response.body); + if (decoded is Map && decoded['error'] == 'Endpoint not found') { + return const R2VerificationResponse.temporarilyUnverifiable( + 'R2 verification endpoint not yet deployed on worker.', + statusCode: 404, + ); + } + } catch (_) {} + 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 02d152d..c228d74 100644 --- a/lib/services/sync/sync_manager.dart +++ b/lib/services/sync/sync_manager.dart @@ -14,6 +14,8 @@ class CompletedRideReconciliationReport { final int recoveredAuthRequiredCount; final int recoveredAlreadySyncedCount; final int foreignOwnerSkippedCount; + final int inconsistentSkippedCount; + final int unverifiablePendingCount; final int errorCount; final List errors; @@ -24,6 +26,8 @@ class CompletedRideReconciliationReport { this.recoveredAuthRequiredCount = 0, this.recoveredAlreadySyncedCount = 0, this.foreignOwnerSkippedCount = 0, + this.inconsistentSkippedCount = 0, + this.unverifiablePendingCount = 0, this.errorCount = 0, this.errors = const [], }); @@ -38,7 +42,8 @@ class CompletedRideReconciliationReport { 'CompletedRideReconciliationReport(discovered: $discoveredCompletedRides, ' 'tracked: $alreadyTrackedCount, queued: $recoveredQueuedCount, ' 'authRequired: $recoveredAuthRequiredCount, alreadySynced: $recoveredAlreadySyncedCount, ' - 'foreignSkipped: $foreignOwnerSkippedCount, errors: $errorCount)'; + 'foreignSkipped: $foreignOwnerSkippedCount, inconsistent: $inconsistentSkippedCount, ' + 'unverifiable: $unverifiablePendingCount, errors: $errorCount)'; } /// Central provider-agnostic synchronization coordinator. @@ -58,11 +63,21 @@ class SyncManager extends ChangeNotifier with WidgetsBindingObserver { final AppDatabase? _db; final AuthService? _authService; - SyncManager(this._outbox, {List? initialProviders, String? defaultProviderId, DateTime? Function()? nowProvider, this._db, this._authService, bool autoStart = true}) : _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; + if (initialProviders != null) { + for (final p in initialProviders) { + registerProvider(p); + } + } WidgetsBinding.instance.addObserver(this); if (autoStart) { unawaited(_startupRecoveryAndProcess()); @@ -72,7 +87,13 @@ class SyncManager extends ChangeNotifier with WidgetsBindingObserver { 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(); @@ -85,29 +106,50 @@ 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) { _providers[provider.providerId] = provider; - if (_activeProviderId == 'none' || _activeProviderId == 'fake') _activeProviderId = provider.providerId; + if (_activeProviderId == 'none' || _activeProviderId == 'fake') { + _activeProviderId = provider.providerId; + } notifyListeners(); } + 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 (isAuthenticated && rideOwner == null) { await _db.claimRideForCloudOwner(localRideId, user.uid); } } @@ -117,10 +159,17 @@ 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()); @@ -138,11 +187,36 @@ class SyncManager extends ChangeNotifier with WidgetsBindingObserver { if (isAuthenticated && _db != null) { await reconcileCompletedRides(); } - for (final item in _outbox.getAllItems().where((i) => i.status == SyncStatus.authRequired)) { - if (isAuthenticated && _db != null && item.providerId == 'firebase') { - await _db.claimRideForCloudOwner(item.localRideId, user.uid); + 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 _outbox.update( + item.copyWith( + status: SyncStatus.queued, + clearLastError: true, + clearNextRetry: true, + ), + ); } await processQueue(); } @@ -155,7 +229,9 @@ class SyncManager extends ChangeNotifier with WidgetsBindingObserver { if (_activeReconciliation != null) { return _activeReconciliation!; } - final future = _performReconcileCompletedRides(targetProviderId: targetProviderId); + final future = _performReconcileCompletedRides( + targetProviderId: targetProviderId, + ); _activeReconciliation = future; try { return await future; @@ -182,13 +258,15 @@ class SyncManager extends ChangeNotifier with WidgetsBindingObserver { int recoveredAuthRequired = 0; int recoveredAlreadySynced = 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(); + final completedRides = await (_db.select( + _db.rides, + )..where((r) => r.endTime.isNotNull())).get(); discovered = completedRides.length; for (final ride in completedRides) { @@ -213,52 +291,118 @@ class SyncManager extends ChangeNotifier with WidgetsBindingObserver { } // Claim unclaimed ride if user is authenticated and target is firebase - if (ride.cloudOwnerUid == null && isAuthenticated && providerId == 'firebase') { + if (ride.cloudOwnerUid == null && + isAuthenticated && + providerId == 'firebase') { await _db.claimRideForCloudOwner(ride.id, user.uid); } // Stable cloud identity check final remoteRideId = await _db.ensureCloudRideId(ride.id); - // Check if already confirmed uploaded in cloud backend - final isConfirmed = provider != null && - await provider.isRideConfirmedInCloud(remoteRideId); + // Deep verification check in cloud backend + final confirmation = provider != null + ? await provider.verifyCloudConfirmation(remoteRideId) + : const CloudConfirmationResult( + status: CloudConfirmationStatus.missing, + ); - if (isConfirmed) { - 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++; - } else { - final providerRequiresAuth = provider != null && - await provider.requiresAuthentication(); - - if (providerRequiresAuth || (!isAuthenticated && providerId == 'firebase')) { - final authReqItem = SyncItem( + switch (confirmation.status) { + case CloudConfirmationStatus.confirmed: + final syncedItem = SyncItem( localRideId: ride.id, providerId: providerId, - status: SyncStatus.authRequired, + status: SyncStatus.synced, + remoteId: remoteRideId, createdAt: ride.endTime ?? ride.startTime, - lastError: 'Authentication required for provider "$providerId"', + uploadedAt: _now(), ); - await _outbox.update(authReqItem); - recoveredAuthRequired++; - } else { - final queuedItem = SyncItem( + await _outbox.update(syncedItem); + 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 { + 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.queued, + status: SyncStatus.failed, + remoteId: remoteRideId, createdAt: ride.endTime ?? ride.startTime, + lastError: 'Cloud data inconsistent: ${confirmation.message}', + attemptCount: 1, ); - await _outbox.update(queuedItem); - recoveredQueued++; - } + 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++; @@ -277,6 +421,8 @@ class SyncManager extends ChangeNotifier with WidgetsBindingObserver { recoveredAuthRequiredCount: recoveredAuthRequired, recoveredAlreadySyncedCount: recoveredAlreadySynced, foreignOwnerSkippedCount: foreignOwnerSkipped, + inconsistentSkippedCount: inconsistentSkipped, + unverifiablePendingCount: unverifiablePending, errorCount: errorCount, errors: errors, ); @@ -289,12 +435,18 @@ class SyncManager extends ChangeNotifier with WidgetsBindingObserver { 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); } } @@ -329,34 +481,172 @@ class SyncManager extends ChangeNotifier with WidgetsBindingObserver { 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)); + 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)); + 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)); + 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); + + 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); + 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; + } + if (result.success) { - await _outbox.update(uploadingItem.copyWith(status: SyncStatus.synced, remoteId: result.remoteId, uploadedAt: result.timestamp, clearLastError: true, clearNextRetry: true)); + 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)); + 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)); + 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)); + await _outbox.update( + uploadingItem.copyWith( + status: SyncStatus.failed, + attemptCount: uploadingItem.attemptCount + 1, + lastError: result.errorMessage, + clearNextRetry: true, + ), + ); } } } finally { @@ -375,9 +665,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 18e3db8..3d84bc9 100644 --- a/lib/services/sync/sync_provider.dart +++ b/lib/services/sync/sync_provider.dart @@ -1,6 +1,115 @@ 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, +} + +/// 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, + ); + + bool get isConfirmed => status == CloudConfirmationStatus.confirmed; + 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 { @@ -18,8 +127,17 @@ abstract class SyncProvider { Future uploadRide(SyncItem item); /// Checks if the ride with [remoteRideId] is already confirmed uploaded in the cloud. - /// Default implementation returns false. - Future isRideConfirmedInCloud(String remoteRideId) async => false; + /// Backward compatible wrapper around [verifyCloudConfirmation]. + Future isRideConfirmedInCloud(String remoteRideId) async { + final result = await verifyCloudConfirmation(remoteRideId); + return result.isConfirmed; + } + + /// 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 @@ -41,6 +159,7 @@ class FakeSyncProvider implements SyncProvider { final List uploadedItems = []; final Map uploadAttemptsPerRide = {}; final Set confirmedRemoteRideIds = {}; + final Map confirmationResults = {}; FakeSyncProvider({ this.providerId = 'fake', @@ -61,7 +180,37 @@ class FakeSyncProvider implements SyncProvider { @override Future isRideConfirmedInCloud(String remoteRideId) async { - return confirmedRemoteRideIds.contains(remoteRideId); + final result = await verifyCloudConfirmation(remoteRideId); + return result.isConfirmed; + } + + @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 @@ -76,11 +225,13 @@ class FakeSyncProvider implements SyncProvider { switch (behavior) { case FakeSyncBehavior.success: uploadedItems.add(item); - final remoteId = 'remote_${item.localRideId}_${DateTime.now().millisecondsSinceEpoch}'; + final remoteId = + 'remote_${item.localRideId}_${DateTime.now().millisecondsSinceEpoch}'; confirmedRemoteRideIds.add(remoteId); - return SyncResult.success( - remoteId: remoteId, + confirmationResults[remoteId] = CloudConfirmationResult.confirmed( + remoteRideId: remoteId, ); + return SyncResult.success(remoteId: remoteId); case FakeSyncBehavior.temporaryFailure: return SyncResult.temporaryFailure( @@ -108,5 +259,6 @@ class FakeSyncProvider implements SyncProvider { 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..71dcadf --- /dev/null +++ b/test/cloud_confirmation_contract_test.dart @@ -0,0 +1,566 @@ +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'; + +class _MockAuthService implements AuthService { + AuthUser? _currentUser; + AuthState _authState = const AuthState.unauthenticated(); + String? idTokenToReturn = 'valid_mock_jwt_token'; + + 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 => + 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.confirmed)); + }, + ); + }); +} diff --git a/test/completed_ride_sync_recovery_test.dart b/test/completed_ride_sync_recovery_test.dart index 0449fc0..4d0395a 100644 --- a/test/completed_ride_sync_recovery_test.dart +++ b/test/completed_ride_sync_recovery_test.dart @@ -56,7 +56,11 @@ class _TestAuthService implements AuthService { bool get isEmailVerified => false; @override - Future createUserWithEmailAndPassword(String email, String password, {String? displayName}) async {} + Future createUserWithEmailAndPassword( + String email, + String password, { + String? displayName, + }) async {} @override Future deleteCurrentUser() async {} @@ -68,7 +72,8 @@ class _TestAuthService implements AuthService { Future ensureBackendIdentity() async {} @override - Future getIdToken([bool forceRefresh = false]) async => 'fake_id_token'; + Future getIdToken([bool forceRefresh = false]) async => + 'fake_id_token'; @override Future reauthenticate({String? password}) async {} @@ -86,8 +91,10 @@ class _TestAuthService implements AuthService { Future signInAnonymously() async => setGuestUser(); @override - Future signInWithEmailAndPassword(String email, String password) async => - setAuthenticatedUser('uid_123', email); + Future signInWithEmailAndPassword( + String email, + String password, + ) async => setAuthenticatedUser('uid_123', email); @override Future signInWithGoogle() async => @@ -118,209 +125,512 @@ void main() { 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'); + 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()); + // 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)); + // 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, - ); + // 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(); + // 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)); + 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)); + 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')); + // 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)); - }); + // 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'); + 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 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, - ); + 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)); + // 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)); + // 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)); + // 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)); - }); + // 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; + 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 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, - ); + 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)); + // 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); + // 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; + // 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(); + // 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)); - }); + 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'); + 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); + 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); + // 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 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 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)); + 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); - }); + // 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'); + 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)), - )); + // 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 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)); + 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')); - }); + // 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'); + 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)); + // 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 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); - }); + 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); + }, + ); }); } diff --git a/test/disposable_backup_recovery_verification_test.dart b/test/disposable_backup_recovery_verification_test.dart index 620419e..b550b13 100644 --- a/test/disposable_backup_recovery_verification_test.dart +++ b/test/disposable_backup_recovery_verification_test.dart @@ -15,12 +15,12 @@ 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, - ); + : _user = AuthUser( + uid: uid, + email: '$uid@example.com', + displayName: 'Pixel Pilot', + isAnonymous: false, + ); @override AuthUser? get currentUser => _user; @@ -34,7 +34,11 @@ class _FakeAuthService implements AuthService { bool get isEmailVerified => true; @override - Future createUserWithEmailAndPassword(String email, String password, {String? displayName}) async {} + Future createUserWithEmailAndPassword( + String email, + String password, { + String? displayName, + }) async {} @override Future deleteCurrentUser() async {} @override @@ -54,7 +58,10 @@ class _FakeAuthService implements AuthService { @override Future signInAnonymously() async {} @override - Future signInWithEmailAndPassword(String email, String password) async {} + Future signInWithEmailAndPassword( + String email, + String password, + ) async {} @override Future signInWithGoogle() async {} @override @@ -69,22 +76,33 @@ void main() { r'C:\Users\Bill\Desktop\flutter-app\output\master-consolidation-20261012\backup-071303\motorsignal.sqlite', ); - expect(originalFile.existsSync(), isTrue, reason: 'Original backup file must exist'); + expect( + originalFile.existsSync(), + isTrue, + reason: 'Original backup file must exist', + ); // 1. Verify original SHA-256 before copy final originalBytes = await originalFile.readAsBytes(); final originalHash = sha256.convert(originalBytes).toString(); - const expectedHash = '7125915195cdcf1de2d351426d8a991789904e7c3befd12af6f0d0f0b2416122'; - expect(originalHash, equals(expectedHash), reason: 'Original backup must match authoritative SHA-256'); + 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( - r'C:\Users\Bill\.gemini\antigravity\brain\eb590221-346f-4525-a5fa-eda41331c6c6\scratch', + '${Directory.systemTemp.path}/motorsignal_backup_test_${DateTime.now().millisecondsSinceEpoch}', ); if (!scratchDir.existsSync()) { scratchDir.createSync(recursive: true); } - final disposableFile = File('${scratchDir.path}\\disposable_motorsignal.sqlite'); + final disposableFile = File( + '${scratchDir.path}/disposable_motorsignal.sqlite', + ); if (disposableFile.existsSync()) { disposableFile.deleteSync(); } @@ -96,21 +114,39 @@ void main() { try { // Integrity check - final integrityRows = await db.customSelect('PRAGMA integrity_check').get(); + 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'); + 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 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 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 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'); + 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' @@ -133,7 +169,11 @@ void main() { 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.foreignOwnerSkippedCount, + equals(completedRides), + reason: 'Foreign account must NEVER claim or touch real Pixel rides', + ); expect(foreignReport.totalRecovered, equals(0)); expect(outbox.count, equals(0)); @@ -160,7 +200,6 @@ void main() { expect(report2.totalRecovered, equals(0)); expect(report2.alreadyTrackedCount, equals(completedRides)); expect(outbox.count, equals(completedRides)); - } finally { await db.close(); } @@ -168,6 +207,10 @@ void main() { // 6. Verify original SHA-256 after operations to prove zero mutation final postBytes = await originalFile.readAsBytes(); final postHash = sha256.convert(postBytes).toString(); - expect(postHash, equals(expectedHash), reason: 'Original backup MUST remain 100% UNTOUCHED'); + expect( + postHash, + equals(expectedHash), + reason: 'Original backup MUST remain 100% UNTOUCHED', + ); }); } diff --git a/test/isolated_sync_integration_fixture_test.dart b/test/isolated_sync_integration_fixture_test.dart index 0d7b38e..44223ee 100644 --- a/test/isolated_sync_integration_fixture_test.dart +++ b/test/isolated_sync_integration_fixture_test.dart @@ -107,6 +107,10 @@ class _IsolatedContractSyncProvider implements SyncProvider { @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 db7fe3a..74248f4 100644 --- a/test/offline_first_sync_contract_test.dart +++ b/test/offline_first_sync_contract_test.dart @@ -23,6 +23,10 @@ class _CountingProvider implements SyncProvider { @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 245bd92..c5c2bd2 100644 --- a/test/r2_firestore_retry_hardening_test.dart +++ b/test/r2_firestore_retry_hardening_test.dart @@ -34,6 +34,10 @@ class _R2ThenFirestoreProvider implements SyncProvider { @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 4d5978c..4372913 100644 --- a/test/sync_persistence_validation_test.dart +++ b/test/sync_persistence_validation_test.dart @@ -24,6 +24,10 @@ class _ValidationProvider implements SyncProvider { @override Future isRideConfirmedInCloud(String remoteRideId) async => false; + @override + Future verifyCloudConfirmation(String remoteRideId) async => + CloudConfirmationResult.missing(remoteRideId: remoteRideId); + @override Future uploadRide(SyncItem item) async { uploadCount++;