fix(sync): harden cloud confirmation with R2 verification and account isolation
This commit is contained in:
parent
c6c8c8df29
commit
d431f11a13
|
|
@ -146,10 +146,76 @@ export default {
|
||||||
headers.set('X-MotorSignal-Ride-Id', remoteRideId);
|
headers.set('X-MotorSignal-Ride-Id', remoteRideId);
|
||||||
const sha256 = object.customMetadata?.sha256;
|
const sha256 = object.customMetadata?.sha256;
|
||||||
if (sha256) headers.set('X-MotorSignal-Sha256', 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);
|
for (const [k, v] of Object.entries(CORS_HEADERS)) headers.set(k, v);
|
||||||
return new Response(object.body, { status: 200, headers });
|
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);
|
return jsonResponse({ error: 'Endpoint not found' }, 404);
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
console.error('MotorSignal Worker exception', err);
|
console.error('MotorSignal Worker exception', err);
|
||||||
|
|
|
||||||
|
|
@ -16,14 +16,21 @@ class FirebaseSyncProvider implements SyncProvider {
|
||||||
final R2UploadService _r2UploadService;
|
final R2UploadService _r2UploadService;
|
||||||
final SyncBundleWorker _bundleWorker;
|
final SyncBundleWorker _bundleWorker;
|
||||||
|
|
||||||
FirebaseSyncProvider({required AppDatabase db, required this._authService, FirebaseFirestore? firestore, R2UploadService? r2UploadService, SyncBundleWorker? bundleWorker})
|
FirebaseSyncProvider({
|
||||||
: _db = db,
|
required AppDatabase db,
|
||||||
_firestore = firestore ?? _resolveFirestore(),
|
required this._authService,
|
||||||
_r2UploadService = r2UploadService ?? R2UploadService(),
|
FirebaseFirestore? firestore,
|
||||||
_bundleWorker = bundleWorker ?? SyncBundleWorker(db);
|
R2UploadService? r2UploadService,
|
||||||
|
SyncBundleWorker? bundleWorker,
|
||||||
|
}) : _db = db,
|
||||||
|
_firestore = firestore ?? _resolveFirestore(),
|
||||||
|
_r2UploadService = r2UploadService ?? R2UploadService(),
|
||||||
|
_bundleWorker = bundleWorker ?? SyncBundleWorker(db);
|
||||||
|
|
||||||
static FirebaseFirestore? _resolveFirestore() {
|
static FirebaseFirestore? _resolveFirestore() {
|
||||||
try { if (Firebase.apps.isNotEmpty) return FirebaseFirestore.instance; } catch (_) {}
|
try {
|
||||||
|
if (Firebase.apps.isNotEmpty) return FirebaseFirestore.instance;
|
||||||
|
} catch (_) {}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -32,7 +39,7 @@ class FirebaseSyncProvider implements SyncProvider {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<bool> isAvailable() async {
|
Future<bool> isAvailable() async {
|
||||||
try { return Firebase.apps.isNotEmpty && _firestore != null; } catch (_) { return false; }
|
return _firestore != null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -43,35 +50,209 @@ class FirebaseSyncProvider implements SyncProvider {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<bool> isRideConfirmedInCloud(String remoteRideId) async {
|
Future<bool> isRideConfirmedInCloud(String remoteRideId) async {
|
||||||
|
final result = await verifyCloudConfirmation(remoteRideId);
|
||||||
|
return result.isConfirmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<CloudConfirmationResult> verifyCloudConfirmation(
|
||||||
|
String remoteRideId,
|
||||||
|
) async {
|
||||||
final user = _authService.currentUser;
|
final user = _authService.currentUser;
|
||||||
if (user == null || user.isAnonymous) return false;
|
if (user == null || user.isAnonymous) {
|
||||||
if (!await isAvailable()) return false;
|
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<Map<String, dynamic>> doc;
|
||||||
try {
|
try {
|
||||||
final doc = await _firestore!
|
doc = await _firestore!
|
||||||
.collection('users')
|
.collection('users')
|
||||||
.doc(user.uid)
|
.doc(user.uid)
|
||||||
.collection('rides')
|
.collection('rides')
|
||||||
.doc(remoteRideId)
|
.doc(remoteRideId)
|
||||||
.get();
|
.get();
|
||||||
if (!doc.exists) return false;
|
} on FirebaseException catch (e) {
|
||||||
final data = doc.data();
|
if (e.code == 'permission-denied' || e.code == 'unauthenticated') {
|
||||||
if (data == null) return false;
|
return CloudConfirmationResult.unauthorized(
|
||||||
final bundle = data['telemetryBundle'];
|
remoteRideId: remoteRideId,
|
||||||
if (bundle is Map && bundle['status'] == 'uploaded') {
|
ownerUid: user.uid,
|
||||||
return true;
|
message: 'Firestore access unauthorized (${e.code}): ${e.message}',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return false;
|
return CloudConfirmationResult.temporarilyUnverifiable(
|
||||||
} catch (_) {
|
remoteRideId: remoteRideId,
|
||||||
return false;
|
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
|
@override
|
||||||
Future<SyncResult> uploadRide(SyncItem item) async {
|
Future<SyncResult> uploadRide(SyncItem item) async {
|
||||||
final user = _authService.currentUser;
|
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) {
|
if (ride != null && ride.cloudOwnerUid != user.uid) {
|
||||||
return SyncResult.permanentFailure(
|
return SyncResult.permanentFailure(
|
||||||
ride.cloudOwnerUid == null
|
ride.cloudOwnerUid == null
|
||||||
|
|
@ -79,7 +260,11 @@ class FirebaseSyncProvider implements SyncProvider {
|
||||||
: 'This ride belongs to another MotorCloud account and was not uploaded.',
|
: '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
|
// Refresh the Firebase ID token before a manual upload. This prevents a
|
||||||
// recently completed sign-in from being rejected because the cached token
|
// recently completed sign-in from being rejected because the cached token
|
||||||
|
|
@ -88,11 +273,21 @@ class FirebaseSyncProvider implements SyncProvider {
|
||||||
if (idToken == null || idToken.isEmpty) {
|
if (idToken == null || idToken.isEmpty) {
|
||||||
idToken = await _authService.getIdToken(true);
|
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 {
|
try {
|
||||||
final ride = await (_db.select(_db.rides)..where((r) => r.id.equals(item.localRideId))).getSingleOrNull();
|
final ride = await (_db.select(
|
||||||
if (ride == null) return SyncResult.permanentFailure('Local ride #${item.localRideId} not found in database.');
|
_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
|
// The cloud identity is generated once and then reused forever. Never
|
||||||
// derive the remote identity from the local auto-increment database ID.
|
// derive the remote identity from the local auto-increment database ID.
|
||||||
|
|
@ -102,8 +297,14 @@ class FirebaseSyncProvider implements SyncProvider {
|
||||||
}
|
}
|
||||||
|
|
||||||
final appIdentity = AppIdentity.current();
|
final appIdentity = AppIdentity.current();
|
||||||
final zipBytes = await _bundleWorker.generateCompressedBundle(item.localRideId);
|
final zipBytes = await _bundleWorker.generateCompressedBundle(
|
||||||
if (zipBytes == null || zipBytes.isEmpty) return SyncResult.permanentFailure('Failed generating compressed telemetry bundle for ride #${item.localRideId}.');
|
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();
|
final sha256Hash = sha256.convert(zipBytes).toString();
|
||||||
|
|
||||||
var r2Result = await _r2UploadService.uploadTelemetryBundle(
|
var r2Result = await _r2UploadService.uploadTelemetryBundle(
|
||||||
|
|
@ -119,7 +320,9 @@ class FirebaseSyncProvider implements SyncProvider {
|
||||||
// turning a valid signed-in session into a false authentication error.
|
// turning a valid signed-in session into a false authentication error.
|
||||||
if (!r2Result.success && r2Result.requiresAuth) {
|
if (!r2Result.success && r2Result.requiresAuth) {
|
||||||
final refreshedToken = await _authService.getIdToken(true);
|
final refreshedToken = await _authService.getIdToken(true);
|
||||||
if (refreshedToken != null && refreshedToken.isNotEmpty && refreshedToken != idToken) {
|
if (refreshedToken != null &&
|
||||||
|
refreshedToken.isNotEmpty &&
|
||||||
|
refreshedToken != idToken) {
|
||||||
idToken = refreshedToken;
|
idToken = refreshedToken;
|
||||||
r2Result = await _r2UploadService.uploadTelemetryBundle(
|
r2Result = await _r2UploadService.uploadTelemetryBundle(
|
||||||
idToken: idToken,
|
idToken: idToken,
|
||||||
|
|
@ -132,15 +335,34 @@ class FirebaseSyncProvider implements SyncProvider {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!r2Result.success) {
|
if (!r2Result.success) {
|
||||||
if (r2Result.requiresAuth) return SyncResult.authRequired(message: r2Result.errorMessage ?? 'R2 Gateway authentication required');
|
if (r2Result.requiresAuth) {
|
||||||
if (r2Result.isRetryable) return SyncResult.temporaryFailure(r2Result.errorMessage ?? 'Temporary R2 upload error');
|
return SyncResult.authRequired(
|
||||||
return SyncResult.permanentFailure(r2Result.errorMessage ?? 'Permanent R2 upload rejection');
|
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 objectKey =
|
||||||
final events = await (_db.select(_db.rideEvents)..where((e) => e.rideId.equals(item.localRideId))).get();
|
r2Result.objectKey ??
|
||||||
final performanceRuns = await (_db.select(_db.performanceRuns)..where((p) => p.rideId.equals(item.localRideId))).get();
|
'users/${user.uid}/rides/$remoteRideId/telemetry.zip';
|
||||||
final summary = await (_db.select(_db.fieldTestSummaries)..where((s) => s.rideId.equals(item.localRideId))).getSingleOrNull();
|
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 = <String, dynamic>{
|
final firestorePayload = <String, dynamic>{
|
||||||
'id': remoteRideId,
|
'id': remoteRideId,
|
||||||
|
|
@ -166,12 +388,37 @@ class FirebaseSyncProvider implements SyncProvider {
|
||||||
'motorcycleModel': ride.motorcycleModel,
|
'motorcycleModel': ride.motorcycleModel,
|
||||||
'motorcycleYear': ride.motorcycleYear,
|
'motorcycleYear': ride.motorcycleYear,
|
||||||
'motorcycleVariant': ride.motorcycleVariant,
|
'motorcycleVariant': ride.motorcycleVariant,
|
||||||
'hardBrakingEvents': events.where((e) => e.eventType.contains('Braking')).length,
|
'hardBrakingEvents': events
|
||||||
'extremeLeanEvents': events.where((e) => e.eventType.contains('Lean') || e.eventType.contains('Corner')).length,
|
.where((e) => e.eventType.contains('Braking'))
|
||||||
'highSpeedEvents': events.where((e) => e.eventType.contains('Speed')).length,
|
.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,
|
'totalEventsCount': events.length,
|
||||||
'performanceRuns': performanceRuns.map((r) => {'type': r.type, 'timeMs': r.timeMs, 'topSpeedReached': r.topSpeedReached, 'timestamp': r.timestamp.toUtc().toIso8601String()}).toList(),
|
'performanceRuns': performanceRuns
|
||||||
'telemetryBundle': {'provider': 'cloudflare-r2', 'objectKey': objectKey, 'status': 'uploaded', 'bundleSizeBytes': zipBytes.length, 'sha256': sha256Hash},
|
.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,
|
'telemetryBundlePath': objectKey,
|
||||||
'bundleSizeBytes': zipBytes.length,
|
'bundleSizeBytes': zipBytes.length,
|
||||||
'syncVersion': 3,
|
'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));
|
await _firestore!.collection('users').doc(user.uid).collection('rides').doc(remoteRideId).set(firestorePayload, SetOptions(merge: true));
|
||||||
return SyncResult.success(remoteId: remoteRideId);
|
return SyncResult.success(remoteId: remoteRideId);
|
||||||
} on FirebaseException catch (e) {
|
} on FirebaseException catch (e) {
|
||||||
if (e.code == 'permission-denied' || e.code == 'unauthenticated') return SyncResult.authRequired(message: 'Firebase authorization error: ${e.message}');
|
if (e.code == 'permission-denied' || e.code == 'unauthenticated') {
|
||||||
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.authRequired(
|
||||||
return SyncResult.permanentFailure('Firebase sync error (${e.code}): ${e.message}');
|
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) {
|
} catch (e) {
|
||||||
return SyncResult.temporaryFailure('Unexpected sync exception: $e');
|
return SyncResult.temporaryFailure('Unexpected sync exception: $e');
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,10 +9,7 @@ class GitHubSyncProvider implements SyncProvider {
|
||||||
final GitHubSyncService service;
|
final GitHubSyncService service;
|
||||||
final GitHubAuthService auth;
|
final GitHubAuthService auth;
|
||||||
|
|
||||||
GitHubSyncProvider({
|
GitHubSyncProvider({required this.service, required this.auth});
|
||||||
required this.service,
|
|
||||||
required this.auth,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get providerId => 'github';
|
String get providerId => 'github';
|
||||||
|
|
@ -26,12 +23,18 @@ class GitHubSyncProvider implements SyncProvider {
|
||||||
@override
|
@override
|
||||||
Future<bool> isRideConfirmedInCloud(String remoteRideId) async => false;
|
Future<bool> isRideConfirmedInCloud(String remoteRideId) async => false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<CloudConfirmationResult> verifyCloudConfirmation(
|
||||||
|
String remoteRideId,
|
||||||
|
) async => CloudConfirmationResult.missing(remoteRideId: remoteRideId);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<SyncResult> uploadRide(SyncItem item) async {
|
Future<SyncResult> uploadRide(SyncItem item) async {
|
||||||
final hasAuth = auth.getToken() != null;
|
final hasAuth = auth.getToken() != null;
|
||||||
if (!hasAuth) {
|
if (!hasAuth) {
|
||||||
return SyncResult.authRequired(
|
return SyncResult.authRequired(
|
||||||
message: 'GitHub developer export requires an explicitly configured GitHub token.',
|
message:
|
||||||
|
'GitHub developer export requires an explicitly configured GitHub token.',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,84 @@ import 'dart:async';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:http/http.dart' as http;
|
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
|
@immutable
|
||||||
class R2UploadResult {
|
class R2UploadResult {
|
||||||
|
|
@ -12,59 +90,332 @@ class R2UploadResult {
|
||||||
final int? statusCode;
|
final int? statusCode;
|
||||||
final bool isRetryable;
|
final bool isRetryable;
|
||||||
final bool requiresAuth;
|
final bool requiresAuth;
|
||||||
const R2UploadResult({required this.success, this.objectKey, this.bytesWritten, this.errorMessage, this.statusCode, this.isRetryable = true, this.requiresAuth = false});
|
const R2UploadResult({
|
||||||
const R2UploadResult.success({required String objectKey, int? bytesWritten}) : this(success: true, objectKey: objectKey, bytesWritten: bytesWritten, statusCode: 200, isRetryable: false);
|
required this.success,
|
||||||
const R2UploadResult.authRequired(String message, {int statusCode = 401}) : this(success: false, errorMessage: message, statusCode: statusCode, isRetryable: true, requiresAuth: true);
|
this.objectKey,
|
||||||
const R2UploadResult.temporaryFailure(String message, {int? statusCode}) : this(success: false, errorMessage: message, statusCode: statusCode, isRetryable: true);
|
this.bytesWritten,
|
||||||
const R2UploadResult.permanentFailure(String message, {int? statusCode}) : this(success: false, errorMessage: message, statusCode: statusCode, isRetryable: false);
|
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.
|
/// Uploads immutable ride telemetry bundles through the authenticated Worker gateway.
|
||||||
class R2UploadService {
|
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 http.Client _client;
|
||||||
final String _workerBaseUrl;
|
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;
|
String get workerBaseUrl => _workerBaseUrl;
|
||||||
|
|
||||||
Future<R2UploadResult> uploadTelemetryBundle({required String idToken, required String remoteRideId, required Uint8List zipBytes, required String sha256Hash, required String appVersion, Duration timeout = const Duration(seconds: 45)}) async {
|
Future<R2UploadResult> uploadTelemetryBundle({
|
||||||
if (idToken.trim().isEmpty) return const R2UploadResult.authRequired('Firebase ID token is required for R2 upload.');
|
required String idToken,
|
||||||
if (zipBytes.isEmpty) return const R2UploadResult.permanentFailure('Telemetry bundle is empty.', statusCode: 400);
|
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
|
// Prevent path traversal at the client boundary. The Worker remains the
|
||||||
// authoritative verifier of SHA-256 and Firebase UID ownership.
|
// 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 (!RegExp(r'^ride_[A-Za-z0-9_-]+$').hasMatch(remoteRideId)) {
|
||||||
if (sha256Hash.trim().isEmpty) return const R2UploadResult.permanentFailure('Telemetry bundle SHA-256 is required.', statusCode: 400);
|
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 {
|
try {
|
||||||
final response = await _client.put(Uri.parse('$_workerBaseUrl/r2/upload'), headers: {
|
final response = await _client
|
||||||
'Authorization': 'Bearer ${idToken.trim()}',
|
.put(
|
||||||
'Content-Type': 'application/zip',
|
Uri.parse('$_workerBaseUrl/r2/upload'),
|
||||||
'X-Remote-Ride-Id': remoteRideId,
|
headers: {
|
||||||
'X-Bundle-Sha256': sha256Hash.trim().toLowerCase(),
|
'Authorization': 'Bearer ${idToken.trim()}',
|
||||||
'X-App-Version': appVersion,
|
'Content-Type': 'application/zip',
|
||||||
}, body: zipBytes).timeout(timeout);
|
'X-Remote-Ride-Id': remoteRideId,
|
||||||
|
'X-Bundle-Sha256': sha256Hash.trim().toLowerCase(),
|
||||||
|
'X-App-Version': appVersion,
|
||||||
|
},
|
||||||
|
body: zipBytes,
|
||||||
|
)
|
||||||
|
.timeout(timeout);
|
||||||
final status = response.statusCode;
|
final status = response.statusCode;
|
||||||
if (status >= 200 && status < 300) {
|
if (status >= 200 && status < 300) {
|
||||||
try {
|
try {
|
||||||
final decoded = jsonDecode(response.body);
|
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<String, dynamic>.from(decoded);
|
final data = Map<String, dynamic>.from(decoded);
|
||||||
final objectKey = data['objectKey'];
|
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 size = data['size'];
|
||||||
final bytes = size is num && size >= 0 ? size.toInt() : zipBytes.length;
|
final bytes = size is num && size >= 0
|
||||||
return R2UploadResult.success(objectKey: objectKey, bytesWritten: bytes);
|
? size.toInt()
|
||||||
|
: zipBytes.length;
|
||||||
|
return R2UploadResult.success(
|
||||||
|
objectKey: objectKey,
|
||||||
|
bytesWritten: bytes,
|
||||||
|
);
|
||||||
} catch (e) {
|
} 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 == 401 || status == 403) {
|
||||||
if (status == 400 || status == 404 || status == 409 || status == 422) return R2UploadResult.permanentFailure('R2 Worker rejected the bundle ($status).', statusCode: status);
|
return R2UploadResult.authRequired(
|
||||||
return R2UploadResult.temporaryFailure('R2 Worker temporary error ($status).', statusCode: status);
|
'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 {
|
} 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) {
|
} 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<R2VerificationResponse> 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<String, dynamic>.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',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,8 @@ class CompletedRideReconciliationReport {
|
||||||
final int recoveredAuthRequiredCount;
|
final int recoveredAuthRequiredCount;
|
||||||
final int recoveredAlreadySyncedCount;
|
final int recoveredAlreadySyncedCount;
|
||||||
final int foreignOwnerSkippedCount;
|
final int foreignOwnerSkippedCount;
|
||||||
|
final int inconsistentSkippedCount;
|
||||||
|
final int unverifiablePendingCount;
|
||||||
final int errorCount;
|
final int errorCount;
|
||||||
final List<String> errors;
|
final List<String> errors;
|
||||||
|
|
||||||
|
|
@ -24,6 +26,8 @@ class CompletedRideReconciliationReport {
|
||||||
this.recoveredAuthRequiredCount = 0,
|
this.recoveredAuthRequiredCount = 0,
|
||||||
this.recoveredAlreadySyncedCount = 0,
|
this.recoveredAlreadySyncedCount = 0,
|
||||||
this.foreignOwnerSkippedCount = 0,
|
this.foreignOwnerSkippedCount = 0,
|
||||||
|
this.inconsistentSkippedCount = 0,
|
||||||
|
this.unverifiablePendingCount = 0,
|
||||||
this.errorCount = 0,
|
this.errorCount = 0,
|
||||||
this.errors = const [],
|
this.errors = const [],
|
||||||
});
|
});
|
||||||
|
|
@ -38,7 +42,8 @@ class CompletedRideReconciliationReport {
|
||||||
'CompletedRideReconciliationReport(discovered: $discoveredCompletedRides, '
|
'CompletedRideReconciliationReport(discovered: $discoveredCompletedRides, '
|
||||||
'tracked: $alreadyTrackedCount, queued: $recoveredQueuedCount, '
|
'tracked: $alreadyTrackedCount, queued: $recoveredQueuedCount, '
|
||||||
'authRequired: $recoveredAuthRequiredCount, alreadySynced: $recoveredAlreadySyncedCount, '
|
'authRequired: $recoveredAuthRequiredCount, alreadySynced: $recoveredAlreadySyncedCount, '
|
||||||
'foreignSkipped: $foreignOwnerSkippedCount, errors: $errorCount)';
|
'foreignSkipped: $foreignOwnerSkippedCount, inconsistent: $inconsistentSkippedCount, '
|
||||||
|
'unverifiable: $unverifiablePendingCount, errors: $errorCount)';
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Central provider-agnostic synchronization coordinator.
|
/// Central provider-agnostic synchronization coordinator.
|
||||||
|
|
@ -58,11 +63,21 @@ class SyncManager extends ChangeNotifier with WidgetsBindingObserver {
|
||||||
final AppDatabase? _db;
|
final AppDatabase? _db;
|
||||||
final AuthService? _authService;
|
final AuthService? _authService;
|
||||||
|
|
||||||
SyncManager(this._outbox, {List<SyncProvider>? initialProviders, String? defaultProviderId, DateTime? Function()? nowProvider, this._db, this._authService, bool autoStart = true}) : _nowProvider = nowProvider ?? (() => DateTime.now()) {
|
SyncManager(
|
||||||
if (initialProviders != null) {
|
this._outbox, {
|
||||||
for (final p in initialProviders) { registerProvider(p); }
|
List<SyncProvider>? initialProviders,
|
||||||
}
|
String? defaultProviderId,
|
||||||
|
DateTime? Function()? nowProvider,
|
||||||
|
this._db,
|
||||||
|
this._authService,
|
||||||
|
bool autoStart = true,
|
||||||
|
}) : _nowProvider = nowProvider ?? (() => DateTime.now()) {
|
||||||
if (defaultProviderId != null) _activeProviderId = defaultProviderId;
|
if (defaultProviderId != null) _activeProviderId = defaultProviderId;
|
||||||
|
if (initialProviders != null) {
|
||||||
|
for (final p in initialProviders) {
|
||||||
|
registerProvider(p);
|
||||||
|
}
|
||||||
|
}
|
||||||
WidgetsBinding.instance.addObserver(this);
|
WidgetsBinding.instance.addObserver(this);
|
||||||
if (autoStart) {
|
if (autoStart) {
|
||||||
unawaited(_startupRecoveryAndProcess());
|
unawaited(_startupRecoveryAndProcess());
|
||||||
|
|
@ -72,7 +87,13 @@ class SyncManager extends ChangeNotifier with WidgetsBindingObserver {
|
||||||
void setNowProvider(DateTime? Function() provider) => _nowProvider = provider;
|
void setNowProvider(DateTime? Function() provider) => _nowProvider = provider;
|
||||||
DateTime _now() => _nowProvider() ?? DateTime.now();
|
DateTime _now() => _nowProvider() ?? DateTime.now();
|
||||||
String get activeProviderId => _activeProviderId;
|
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;
|
bool get isProcessing => _isProcessing;
|
||||||
int get outboxCount => _outbox.count;
|
int get outboxCount => _outbox.count;
|
||||||
List<SyncItem> get allItems => _outbox.getAllItems();
|
List<SyncItem> get allItems => _outbox.getAllItems();
|
||||||
|
|
@ -85,29 +106,50 @@ class SyncManager extends ChangeNotifier with WidgetsBindingObserver {
|
||||||
for (final item in _outbox.getAllItems()) {
|
for (final item in _outbox.getAllItems()) {
|
||||||
if (item.status == SyncStatus.synced) {
|
if (item.status == SyncStatus.synced) {
|
||||||
synced++;
|
synced++;
|
||||||
if (item.uploadedAt != null && (lastSuccess == null || item.uploadedAt!.isAfter(lastSuccess))) lastSuccess = item.uploadedAt;
|
if (item.uploadedAt != null &&
|
||||||
} else if (item.status == SyncStatus.failed || item.status == SyncStatus.cancelled) {
|
(lastSuccess == null || item.uploadedAt!.isAfter(lastSuccess))) {
|
||||||
|
lastSuccess = item.uploadedAt;
|
||||||
|
}
|
||||||
|
} else if (item.status == SyncStatus.failed ||
|
||||||
|
item.status == SyncStatus.cancelled) {
|
||||||
failed++;
|
failed++;
|
||||||
if (item.lastError != null) lastErr = item.lastError;
|
if (item.lastError != null) lastErr = item.lastError;
|
||||||
} else {
|
} else {
|
||||||
pending++;
|
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) {
|
void registerProvider(SyncProvider provider) {
|
||||||
_providers[provider.providerId] = provider;
|
_providers[provider.providerId] = provider;
|
||||||
if (_activeProviderId == 'none' || _activeProviderId == 'fake') _activeProviderId = provider.providerId;
|
if (_activeProviderId == 'none' || _activeProviderId == 'fake') {
|
||||||
|
_activeProviderId = provider.providerId;
|
||||||
|
}
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
SyncProvider? getProvider(String providerId) => _providers[providerId];
|
SyncProvider? getProvider(String providerId) => _providers[providerId];
|
||||||
|
|
||||||
Future<SyncItem> enqueueRide(int localRideId, {String? targetProviderId, bool autoProcess = true}) async {
|
Future<SyncItem> enqueueRide(
|
||||||
|
int localRideId, {
|
||||||
|
String? targetProviderId,
|
||||||
|
bool autoProcess = true,
|
||||||
|
}) async {
|
||||||
final providerId = targetProviderId ?? _activeProviderId;
|
final providerId = targetProviderId ?? _activeProviderId;
|
||||||
if (providerId == 'firebase') {
|
if (providerId == 'firebase' && _db != null) {
|
||||||
final user = _authService?.currentUser;
|
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);
|
await _db.claimRideForCloudOwner(localRideId, user.uid);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -117,10 +159,17 @@ class SyncManager extends ChangeNotifier with WidgetsBindingObserver {
|
||||||
return item;
|
return item;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<int> backupAllRideIds(Iterable<int> rideIds, {String? targetProviderId}) async {
|
Future<int> backupAllRideIds(
|
||||||
|
Iterable<int> rideIds, {
|
||||||
|
String? targetProviderId,
|
||||||
|
}) async {
|
||||||
var enqueued = 0;
|
var enqueued = 0;
|
||||||
for (final rideId in rideIds) {
|
for (final rideId in rideIds) {
|
||||||
await enqueueRide(rideId, targetProviderId: targetProviderId, autoProcess: false);
|
await enqueueRide(
|
||||||
|
rideId,
|
||||||
|
targetProviderId: targetProviderId,
|
||||||
|
autoProcess: false,
|
||||||
|
);
|
||||||
enqueued++;
|
enqueued++;
|
||||||
}
|
}
|
||||||
if (enqueued > 0) unawaited(processQueue());
|
if (enqueued > 0) unawaited(processQueue());
|
||||||
|
|
@ -138,11 +187,36 @@ class SyncManager extends ChangeNotifier with WidgetsBindingObserver {
|
||||||
if (isAuthenticated && _db != null) {
|
if (isAuthenticated && _db != null) {
|
||||||
await reconcileCompletedRides();
|
await reconcileCompletedRides();
|
||||||
}
|
}
|
||||||
for (final item in _outbox.getAllItems().where((i) => i.status == SyncStatus.authRequired)) {
|
for (final item in _outbox.getAllItems().where(
|
||||||
if (isAuthenticated && _db != null && item.providerId == 'firebase') {
|
(i) => i.status == SyncStatus.authRequired,
|
||||||
await _db.claimRideForCloudOwner(item.localRideId, user.uid);
|
)) {
|
||||||
|
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();
|
await processQueue();
|
||||||
}
|
}
|
||||||
|
|
@ -155,7 +229,9 @@ class SyncManager extends ChangeNotifier with WidgetsBindingObserver {
|
||||||
if (_activeReconciliation != null) {
|
if (_activeReconciliation != null) {
|
||||||
return _activeReconciliation!;
|
return _activeReconciliation!;
|
||||||
}
|
}
|
||||||
final future = _performReconcileCompletedRides(targetProviderId: targetProviderId);
|
final future = _performReconcileCompletedRides(
|
||||||
|
targetProviderId: targetProviderId,
|
||||||
|
);
|
||||||
_activeReconciliation = future;
|
_activeReconciliation = future;
|
||||||
try {
|
try {
|
||||||
return await future;
|
return await future;
|
||||||
|
|
@ -182,13 +258,15 @@ class SyncManager extends ChangeNotifier with WidgetsBindingObserver {
|
||||||
int recoveredAuthRequired = 0;
|
int recoveredAuthRequired = 0;
|
||||||
int recoveredAlreadySynced = 0;
|
int recoveredAlreadySynced = 0;
|
||||||
int foreignOwnerSkipped = 0;
|
int foreignOwnerSkipped = 0;
|
||||||
|
int inconsistentSkipped = 0;
|
||||||
|
int unverifiablePending = 0;
|
||||||
int errorCount = 0;
|
int errorCount = 0;
|
||||||
final List<String> errors = [];
|
final List<String> errors = [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final completedRides = await (_db.select(_db.rides)
|
final completedRides = await (_db.select(
|
||||||
..where((r) => r.endTime.isNotNull()))
|
_db.rides,
|
||||||
.get();
|
)..where((r) => r.endTime.isNotNull())).get();
|
||||||
discovered = completedRides.length;
|
discovered = completedRides.length;
|
||||||
|
|
||||||
for (final ride in completedRides) {
|
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
|
// 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);
|
await _db.claimRideForCloudOwner(ride.id, user.uid);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stable cloud identity check
|
// Stable cloud identity check
|
||||||
final remoteRideId = await _db.ensureCloudRideId(ride.id);
|
final remoteRideId = await _db.ensureCloudRideId(ride.id);
|
||||||
|
|
||||||
// Check if already confirmed uploaded in cloud backend
|
// Deep verification check in cloud backend
|
||||||
final isConfirmed = provider != null &&
|
final confirmation = provider != null
|
||||||
await provider.isRideConfirmedInCloud(remoteRideId);
|
? await provider.verifyCloudConfirmation(remoteRideId)
|
||||||
|
: const CloudConfirmationResult(
|
||||||
|
status: CloudConfirmationStatus.missing,
|
||||||
|
);
|
||||||
|
|
||||||
if (isConfirmed) {
|
switch (confirmation.status) {
|
||||||
final syncedItem = SyncItem(
|
case CloudConfirmationStatus.confirmed:
|
||||||
localRideId: ride.id,
|
final syncedItem = SyncItem(
|
||||||
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(
|
|
||||||
localRideId: ride.id,
|
localRideId: ride.id,
|
||||||
providerId: providerId,
|
providerId: providerId,
|
||||||
status: SyncStatus.authRequired,
|
status: SyncStatus.synced,
|
||||||
|
remoteId: remoteRideId,
|
||||||
createdAt: ride.endTime ?? ride.startTime,
|
createdAt: ride.endTime ?? ride.startTime,
|
||||||
lastError: 'Authentication required for provider "$providerId"',
|
uploadedAt: _now(),
|
||||||
);
|
);
|
||||||
await _outbox.update(authReqItem);
|
await _outbox.update(syncedItem);
|
||||||
recoveredAuthRequired++;
|
recoveredAlreadySynced++;
|
||||||
} else {
|
break;
|
||||||
final queuedItem = SyncItem(
|
|
||||||
|
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,
|
localRideId: ride.id,
|
||||||
providerId: providerId,
|
providerId: providerId,
|
||||||
status: SyncStatus.queued,
|
status: SyncStatus.failed,
|
||||||
|
remoteId: remoteRideId,
|
||||||
createdAt: ride.endTime ?? ride.startTime,
|
createdAt: ride.endTime ?? ride.startTime,
|
||||||
|
lastError: 'Cloud data inconsistent: ${confirmation.message}',
|
||||||
|
attemptCount: 1,
|
||||||
);
|
);
|
||||||
await _outbox.update(queuedItem);
|
await _outbox.update(inconsistentItem);
|
||||||
recoveredQueued++;
|
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) {
|
} catch (itemErr) {
|
||||||
errorCount++;
|
errorCount++;
|
||||||
|
|
@ -277,6 +421,8 @@ class SyncManager extends ChangeNotifier with WidgetsBindingObserver {
|
||||||
recoveredAuthRequiredCount: recoveredAuthRequired,
|
recoveredAuthRequiredCount: recoveredAuthRequired,
|
||||||
recoveredAlreadySyncedCount: recoveredAlreadySynced,
|
recoveredAlreadySyncedCount: recoveredAlreadySynced,
|
||||||
foreignOwnerSkippedCount: foreignOwnerSkipped,
|
foreignOwnerSkippedCount: foreignOwnerSkipped,
|
||||||
|
inconsistentSkippedCount: inconsistentSkipped,
|
||||||
|
unverifiablePendingCount: unverifiablePending,
|
||||||
errorCount: errorCount,
|
errorCount: errorCount,
|
||||||
errors: errors,
|
errors: errors,
|
||||||
);
|
);
|
||||||
|
|
@ -289,12 +435,18 @@ class SyncManager extends ChangeNotifier with WidgetsBindingObserver {
|
||||||
|
|
||||||
static Duration calculateRetryDelay(int attemptCount) {
|
static Duration calculateRetryDelay(int attemptCount) {
|
||||||
switch (attemptCount) {
|
switch (attemptCount) {
|
||||||
case 1: return const Duration(seconds: 5);
|
case 1:
|
||||||
case 2: return const Duration(seconds: 30);
|
return const Duration(seconds: 5);
|
||||||
case 3: return const Duration(minutes: 2);
|
case 2:
|
||||||
case 4: return const Duration(minutes: 5);
|
return const Duration(seconds: 30);
|
||||||
case 5: return const Duration(minutes: 15);
|
case 3:
|
||||||
default: return const Duration(minutes: 30);
|
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)) {
|
for (final item in _outbox.getEligibleItems(now)) {
|
||||||
final provider = _providers[item.providerId];
|
final provider = _providers[item.providerId];
|
||||||
if (provider == null) {
|
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;
|
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()) {
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
if (await provider.requiresAuthentication()) {
|
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;
|
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);
|
await _outbox.update(uploadingItem);
|
||||||
SyncResult result;
|
SyncResult result;
|
||||||
try {
|
try {
|
||||||
result = await provider.uploadRide(uploadingItem);
|
result = await provider.uploadRide(uploadingItem);
|
||||||
} catch (e) {
|
} 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) {
|
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) {
|
} 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) {
|
} else if (result.isRetryable) {
|
||||||
final attempts = uploadingItem.attemptCount + 1;
|
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 {
|
} 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 {
|
} finally {
|
||||||
|
|
@ -375,9 +665,37 @@ class SyncManager extends ChangeNotifier with WidgetsBindingObserver {
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> retryAllPending() async {
|
Future<void> 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) {
|
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();
|
await processQueue();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,115 @@
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'sync_models.dart';
|
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).
|
/// Abstract contract for synchronization providers (e.g. Firebase, GitHub, Supabase).
|
||||||
/// Implementations must not leak provider-specific SDK types into the core sync engine.
|
/// Implementations must not leak provider-specific SDK types into the core sync engine.
|
||||||
abstract class SyncProvider {
|
abstract class SyncProvider {
|
||||||
|
|
@ -18,8 +127,17 @@ abstract class SyncProvider {
|
||||||
Future<SyncResult> uploadRide(SyncItem item);
|
Future<SyncResult> uploadRide(SyncItem item);
|
||||||
|
|
||||||
/// Checks if the ride with [remoteRideId] is already confirmed uploaded in the cloud.
|
/// Checks if the ride with [remoteRideId] is already confirmed uploaded in the cloud.
|
||||||
/// Default implementation returns false.
|
/// Backward compatible wrapper around [verifyCloudConfirmation].
|
||||||
Future<bool> isRideConfirmedInCloud(String remoteRideId) async => false;
|
Future<bool> 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<CloudConfirmationResult> verifyCloudConfirmation(
|
||||||
|
String remoteRideId,
|
||||||
|
) async => CloudConfirmationResult.missing(remoteRideId: remoteRideId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Simulated behavior modes for testing the synchronization engine deterministically
|
/// Simulated behavior modes for testing the synchronization engine deterministically
|
||||||
|
|
@ -41,6 +159,7 @@ class FakeSyncProvider implements SyncProvider {
|
||||||
final List<SyncItem> uploadedItems = [];
|
final List<SyncItem> uploadedItems = [];
|
||||||
final Map<int, int> uploadAttemptsPerRide = {};
|
final Map<int, int> uploadAttemptsPerRide = {};
|
||||||
final Set<String> confirmedRemoteRideIds = {};
|
final Set<String> confirmedRemoteRideIds = {};
|
||||||
|
final Map<String, CloudConfirmationResult> confirmationResults = {};
|
||||||
|
|
||||||
FakeSyncProvider({
|
FakeSyncProvider({
|
||||||
this.providerId = 'fake',
|
this.providerId = 'fake',
|
||||||
|
|
@ -61,7 +180,37 @@ class FakeSyncProvider implements SyncProvider {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<bool> isRideConfirmedInCloud(String remoteRideId) async {
|
Future<bool> isRideConfirmedInCloud(String remoteRideId) async {
|
||||||
return confirmedRemoteRideIds.contains(remoteRideId);
|
final result = await verifyCloudConfirmation(remoteRideId);
|
||||||
|
return result.isConfirmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<CloudConfirmationResult> 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
|
@override
|
||||||
|
|
@ -76,11 +225,13 @@ class FakeSyncProvider implements SyncProvider {
|
||||||
switch (behavior) {
|
switch (behavior) {
|
||||||
case FakeSyncBehavior.success:
|
case FakeSyncBehavior.success:
|
||||||
uploadedItems.add(item);
|
uploadedItems.add(item);
|
||||||
final remoteId = 'remote_${item.localRideId}_${DateTime.now().millisecondsSinceEpoch}';
|
final remoteId =
|
||||||
|
'remote_${item.localRideId}_${DateTime.now().millisecondsSinceEpoch}';
|
||||||
confirmedRemoteRideIds.add(remoteId);
|
confirmedRemoteRideIds.add(remoteId);
|
||||||
return SyncResult.success(
|
confirmationResults[remoteId] = CloudConfirmationResult.confirmed(
|
||||||
remoteId: remoteId,
|
remoteRideId: remoteId,
|
||||||
);
|
);
|
||||||
|
return SyncResult.success(remoteId: remoteId);
|
||||||
|
|
||||||
case FakeSyncBehavior.temporaryFailure:
|
case FakeSyncBehavior.temporaryFailure:
|
||||||
return SyncResult.temporaryFailure(
|
return SyncResult.temporaryFailure(
|
||||||
|
|
@ -108,5 +259,6 @@ class FakeSyncProvider implements SyncProvider {
|
||||||
uploadedItems.clear();
|
uploadedItems.clear();
|
||||||
uploadAttemptsPerRide.clear();
|
uploadAttemptsPerRide.clear();
|
||||||
confirmedRemoteRideIds.clear();
|
confirmedRemoteRideIds.clear();
|
||||||
|
confirmationResults.clear();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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<AuthState> get authStateChanges => Stream.value(_authState);
|
||||||
|
@override
|
||||||
|
bool get isConfigured => true;
|
||||||
|
@override
|
||||||
|
bool get isEmailVerified => true;
|
||||||
|
@override
|
||||||
|
Future<String?> getIdToken([bool forceRefresh = false]) async =>
|
||||||
|
idTokenToReturn;
|
||||||
|
@override
|
||||||
|
Future<void> createUserWithEmailAndPassword(
|
||||||
|
String email,
|
||||||
|
String password, {
|
||||||
|
String? displayName,
|
||||||
|
}) async {}
|
||||||
|
@override
|
||||||
|
Future<void> deleteCurrentUser() async {}
|
||||||
|
@override
|
||||||
|
void dispose() {}
|
||||||
|
@override
|
||||||
|
Future<void> ensureBackendIdentity() async {}
|
||||||
|
@override
|
||||||
|
Future<void> reauthenticate({String? password}) async {}
|
||||||
|
@override
|
||||||
|
Future<void> reloadCurrentUser() async {}
|
||||||
|
@override
|
||||||
|
Future<void> sendEmailVerification() async {}
|
||||||
|
@override
|
||||||
|
Future<void> sendPasswordResetEmail(String email) async {}
|
||||||
|
@override
|
||||||
|
Future<void> signInAnonymously() async => setGuest();
|
||||||
|
@override
|
||||||
|
Future<void> signInWithEmailAndPassword(
|
||||||
|
String email,
|
||||||
|
String password,
|
||||||
|
) async => setAuthenticated('user_123', email);
|
||||||
|
@override
|
||||||
|
Future<void> signInWithGoogle() async =>
|
||||||
|
setAuthenticated('user_123', 'rider@google.com');
|
||||||
|
@override
|
||||||
|
Future<void> 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<String, dynamic> 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<void>.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));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -56,7 +56,11 @@ class _TestAuthService implements AuthService {
|
||||||
bool get isEmailVerified => false;
|
bool get isEmailVerified => false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> createUserWithEmailAndPassword(String email, String password, {String? displayName}) async {}
|
Future<void> createUserWithEmailAndPassword(
|
||||||
|
String email,
|
||||||
|
String password, {
|
||||||
|
String? displayName,
|
||||||
|
}) async {}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> deleteCurrentUser() async {}
|
Future<void> deleteCurrentUser() async {}
|
||||||
|
|
@ -68,7 +72,8 @@ class _TestAuthService implements AuthService {
|
||||||
Future<void> ensureBackendIdentity() async {}
|
Future<void> ensureBackendIdentity() async {}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<String?> getIdToken([bool forceRefresh = false]) async => 'fake_id_token';
|
Future<String?> getIdToken([bool forceRefresh = false]) async =>
|
||||||
|
'fake_id_token';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> reauthenticate({String? password}) async {}
|
Future<void> reauthenticate({String? password}) async {}
|
||||||
|
|
@ -86,8 +91,10 @@ class _TestAuthService implements AuthService {
|
||||||
Future<void> signInAnonymously() async => setGuestUser();
|
Future<void> signInAnonymously() async => setGuestUser();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> signInWithEmailAndPassword(String email, String password) async =>
|
Future<void> signInWithEmailAndPassword(
|
||||||
setAuthenticatedUser('uid_123', email);
|
String email,
|
||||||
|
String password,
|
||||||
|
) async => setAuthenticatedUser('uid_123', email);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> signInWithGoogle() async =>
|
Future<void> signInWithGoogle() async =>
|
||||||
|
|
@ -118,209 +125,512 @@ void main() {
|
||||||
await db.close();
|
await db.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('1. Crash Window Recovery: Completed ride missing from outbox is recovered and uploaded', () async {
|
test(
|
||||||
authService.setAuthenticatedUser('rider_alice', 'alice@example.com');
|
'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
|
// Simulate a completed ride committed to SQLite during a previous session
|
||||||
final rideId = await db.startRide(
|
final rideId = await db.startRide(
|
||||||
startTime: DateTime.utc(2026, 10, 12, 10, 0, 0),
|
startTime: DateTime.utc(2026, 10, 12, 10, 0, 0),
|
||||||
motorcycleMake: 'Honda',
|
motorcycleMake: 'Honda',
|
||||||
motorcycleModel: 'CBR600RR',
|
motorcycleModel: 'CBR600RR',
|
||||||
);
|
);
|
||||||
await db.endRide(rideId, const RidesCompanion());
|
await db.endRide(rideId, const RidesCompanion());
|
||||||
|
|
||||||
// Confirm outbox starts empty (app terminated before enqueue ran)
|
// Confirm outbox starts empty (app terminated before enqueue ran)
|
||||||
final outbox = SyncOutbox(prefs);
|
final outbox = SyncOutbox(prefs);
|
||||||
expect(outbox.count, equals(0));
|
expect(outbox.count, equals(0));
|
||||||
|
|
||||||
// Startup initialization of SyncManager (testing step-by-step)
|
// Startup initialization of SyncManager (testing step-by-step)
|
||||||
final manager = SyncManager(
|
final manager = SyncManager(
|
||||||
outbox,
|
outbox,
|
||||||
initialProviders: [fakeProvider],
|
initialProviders: [fakeProvider],
|
||||||
defaultProviderId: 'firebase',
|
defaultProviderId: 'firebase',
|
||||||
db: db,
|
db: db,
|
||||||
authService: authService,
|
authService: authService,
|
||||||
autoStart: false,
|
autoStart: false,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Reconcile runs
|
// Reconcile runs
|
||||||
final report = await manager.reconcileCompletedRides();
|
final report = await manager.reconcileCompletedRides();
|
||||||
|
|
||||||
expect(report.discoveredCompletedRides, equals(1));
|
expect(report.discoveredCompletedRides, equals(1));
|
||||||
expect(report.recoveredQueuedCount, equals(1));
|
expect(report.recoveredQueuedCount, equals(1));
|
||||||
expect(report.alreadyTrackedCount, equals(0));
|
expect(report.alreadyTrackedCount, equals(0));
|
||||||
expect(outbox.count, equals(1));
|
expect(outbox.count, equals(1));
|
||||||
|
|
||||||
final item = outbox.getItem(rideId, 'firebase');
|
final item = outbox.getItem(rideId, 'firebase');
|
||||||
expect(item, isNotNull);
|
expect(item, isNotNull);
|
||||||
expect(item?.status, equals(SyncStatus.queued));
|
expect(item?.status, equals(SyncStatus.queued));
|
||||||
|
|
||||||
// Verify ride ownership was claimed for authenticated user
|
// Verify ride ownership was claimed for authenticated user
|
||||||
final ownerUid = await db.getCloudOwnerUid(rideId);
|
final ownerUid = await db.getCloudOwnerUid(rideId);
|
||||||
expect(ownerUid, equals('rider_alice'));
|
expect(ownerUid, equals('rider_alice'));
|
||||||
|
|
||||||
// Process queue to complete upload
|
// Process queue to complete upload
|
||||||
await manager.processQueue();
|
await manager.processQueue();
|
||||||
expect(outbox.getItem(rideId, 'firebase')?.status, equals(SyncStatus.synced));
|
expect(
|
||||||
expect(fakeProvider.uploadedItems.length, equals(1));
|
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 {
|
test(
|
||||||
authService.setAuthenticatedUser('rider_alice', 'alice@example.com');
|
'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));
|
final rideId = await db.startRide(
|
||||||
await db.endRide(rideId, const RidesCompanion());
|
startTime: DateTime.utc(2026, 10, 12, 11, 0, 0),
|
||||||
|
);
|
||||||
|
await db.endRide(rideId, const RidesCompanion());
|
||||||
|
|
||||||
final outbox = SyncOutbox(prefs);
|
final outbox = SyncOutbox(prefs);
|
||||||
final manager = SyncManager(
|
final manager = SyncManager(
|
||||||
outbox,
|
outbox,
|
||||||
initialProviders: [fakeProvider],
|
initialProviders: [fakeProvider],
|
||||||
defaultProviderId: 'firebase',
|
defaultProviderId: 'firebase',
|
||||||
db: db,
|
db: db,
|
||||||
authService: authService,
|
authService: authService,
|
||||||
autoStart: false,
|
autoStart: false,
|
||||||
);
|
);
|
||||||
|
|
||||||
// First run reconciles and queues the ride
|
// First run reconciles and queues the ride
|
||||||
final report1 = await manager.reconcileCompletedRides();
|
final report1 = await manager.reconcileCompletedRides();
|
||||||
expect(report1.recoveredQueuedCount, equals(1));
|
expect(report1.recoveredQueuedCount, equals(1));
|
||||||
expect(outbox.count, equals(1));
|
expect(outbox.count, equals(1));
|
||||||
|
|
||||||
// Process upload
|
// Process upload
|
||||||
await manager.processQueue();
|
await manager.processQueue();
|
||||||
expect(outbox.getItem(rideId, 'firebase')?.status, equals(SyncStatus.synced));
|
expect(
|
||||||
expect(fakeProvider.uploadedItems.length, equals(1));
|
outbox.getItem(rideId, 'firebase')?.status,
|
||||||
|
equals(SyncStatus.synced),
|
||||||
|
);
|
||||||
|
expect(fakeProvider.uploadedItems.length, equals(1));
|
||||||
|
|
||||||
// Second run reports ride as already tracked, zero duplicates
|
// Second run reports ride as already tracked, zero duplicates
|
||||||
final report2 = await manager.reconcileCompletedRides();
|
final report2 = await manager.reconcileCompletedRides();
|
||||||
expect(report2.discoveredCompletedRides, equals(1));
|
expect(report2.discoveredCompletedRides, equals(1));
|
||||||
expect(report2.alreadyTrackedCount, equals(1));
|
expect(report2.alreadyTrackedCount, equals(1));
|
||||||
expect(report2.recoveredQueuedCount, equals(0));
|
expect(report2.recoveredQueuedCount, equals(0));
|
||||||
expect(report2.totalRecovered, equals(0));
|
expect(report2.totalRecovered, equals(0));
|
||||||
expect(outbox.count, equals(1));
|
expect(outbox.count, equals(1));
|
||||||
|
|
||||||
// Running processQueue again does not re-upload
|
// Running processQueue again does not re-upload
|
||||||
await manager.processQueue();
|
await manager.processQueue();
|
||||||
expect(fakeProvider.uploadedItems.length, equals(1));
|
expect(fakeProvider.uploadedItems.length, equals(1));
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
test('3. Guest State & Auth Transition: Recovers as authRequired then claims and syncs on sign-in', () async {
|
test(
|
||||||
// User is initially a guest and provider requires auth
|
'3. Guest State & Auth Transition: Recovers as authRequired then claims and syncs on sign-in',
|
||||||
authService.setGuestUser();
|
() async {
|
||||||
fakeProvider.behavior = FakeSyncBehavior.authRequired;
|
// 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));
|
final rideId = await db.startRide(
|
||||||
await db.endRide(rideId, const RidesCompanion());
|
startTime: DateTime.utc(2026, 10, 12, 12, 0, 0),
|
||||||
|
);
|
||||||
|
await db.endRide(rideId, const RidesCompanion());
|
||||||
|
|
||||||
final outbox = SyncOutbox(prefs);
|
final outbox = SyncOutbox(prefs);
|
||||||
final manager = SyncManager(
|
final manager = SyncManager(
|
||||||
outbox,
|
outbox,
|
||||||
initialProviders: [fakeProvider],
|
initialProviders: [fakeProvider],
|
||||||
defaultProviderId: 'firebase',
|
defaultProviderId: 'firebase',
|
||||||
db: db,
|
db: db,
|
||||||
authService: authService,
|
authService: authService,
|
||||||
autoStart: false,
|
autoStart: false,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Reconcile as guest -> authRequired
|
// Reconcile as guest -> authRequired
|
||||||
final report = await manager.reconcileCompletedRides();
|
final report = await manager.reconcileCompletedRides();
|
||||||
expect(report.recoveredAuthRequiredCount, equals(1));
|
expect(report.recoveredAuthRequiredCount, equals(1));
|
||||||
expect(outbox.getItem(rideId, 'firebase')?.status, equals(SyncStatus.authRequired));
|
expect(
|
||||||
|
outbox.getItem(rideId, 'firebase')?.status,
|
||||||
|
equals(SyncStatus.authRequired),
|
||||||
|
);
|
||||||
|
|
||||||
// Ownership remains unclaimed
|
// Ownership remains unclaimed
|
||||||
expect(await db.getCloudOwnerUid(rideId), isNull);
|
expect(await db.getCloudOwnerUid(rideId), isNull);
|
||||||
|
|
||||||
// Guest now signs in with Google / Email
|
// Guest now signs in with Google / Email
|
||||||
authService.setAuthenticatedUser('rider_bob', 'bob@example.com');
|
authService.setAuthenticatedUser('rider_bob', 'bob@example.com');
|
||||||
fakeProvider.behavior = FakeSyncBehavior.success;
|
fakeProvider.behavior = FakeSyncBehavior.success;
|
||||||
|
|
||||||
// Invoking retryAuthRequired claims the ride and triggers queue upload
|
// Invoking retryAuthRequired claims the ride and triggers queue upload
|
||||||
await manager.retryAuthRequired();
|
await manager.retryAuthRequired();
|
||||||
|
|
||||||
expect(await db.getCloudOwnerUid(rideId), equals('rider_bob'));
|
expect(await db.getCloudOwnerUid(rideId), equals('rider_bob'));
|
||||||
expect(outbox.getItem(rideId, 'firebase')?.status, equals(SyncStatus.synced));
|
expect(
|
||||||
expect(fakeProvider.uploadedItems.length, equals(1));
|
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 {
|
test(
|
||||||
authService.setAuthenticatedUser('rider_charlie', 'charlie@example.com');
|
'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));
|
final rideId = await db.startRide(
|
||||||
await db.endRide(rideId, const RidesCompanion());
|
startTime: DateTime.utc(2026, 10, 12, 13, 0, 0),
|
||||||
final remoteRideId = await db.ensureCloudRideId(rideId);
|
);
|
||||||
|
await db.endRide(rideId, const RidesCompanion());
|
||||||
|
final remoteRideId = await db.ensureCloudRideId(rideId);
|
||||||
|
|
||||||
// Simulate that cloud backend already has this ride bundle uploaded
|
// Simulate that cloud backend already has this ride bundle uploaded
|
||||||
fakeProvider.confirmedRemoteRideIds.add(remoteRideId);
|
fakeProvider.confirmedRemoteRideIds.add(remoteRideId);
|
||||||
|
|
||||||
final outbox = SyncOutbox(prefs);
|
final outbox = SyncOutbox(prefs);
|
||||||
final manager = SyncManager(
|
final manager = SyncManager(
|
||||||
outbox,
|
outbox,
|
||||||
initialProviders: [fakeProvider],
|
initialProviders: [fakeProvider],
|
||||||
defaultProviderId: 'firebase',
|
defaultProviderId: 'firebase',
|
||||||
db: db,
|
db: db,
|
||||||
authService: authService,
|
authService: authService,
|
||||||
);
|
);
|
||||||
|
|
||||||
final report = await manager.reconcileCompletedRides();
|
final report = await manager.reconcileCompletedRides();
|
||||||
expect(report.recoveredAlreadySyncedCount, equals(1));
|
expect(report.recoveredAlreadySyncedCount, equals(1));
|
||||||
expect(report.recoveredQueuedCount, equals(0));
|
expect(report.recoveredQueuedCount, equals(0));
|
||||||
|
|
||||||
final item = outbox.getItem(rideId, 'firebase');
|
final item = outbox.getItem(rideId, 'firebase');
|
||||||
expect(item?.status, equals(SyncStatus.synced));
|
expect(item?.status, equals(SyncStatus.synced));
|
||||||
expect(item?.remoteId, equals(remoteRideId));
|
expect(item?.remoteId, equals(remoteRideId));
|
||||||
|
|
||||||
// Zero upload attempts because it was already confirmed
|
// Zero upload attempts because it was already confirmed
|
||||||
expect(fakeProvider.uploadedItems.isEmpty, isTrue);
|
expect(fakeProvider.uploadedItems.isEmpty, isTrue);
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
test('5. Foreign Account Isolation: Does not claim or upload rides belonging to another user', () async {
|
test(
|
||||||
authService.setAuthenticatedUser('rider_alice', 'alice@example.com');
|
'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
|
// Create ride explicitly owned by rider_bob
|
||||||
final rideId = await db.into(db.rides).insert(RidesCompanion.insert(
|
final rideId = await db
|
||||||
cloudRideId: const drift.Value('ride_bob_123'),
|
.into(db.rides)
|
||||||
cloudOwnerUid: const drift.Value('rider_bob'),
|
.insert(
|
||||||
startTime: DateTime.utc(2026, 10, 12, 14, 0, 0),
|
RidesCompanion.insert(
|
||||||
endTime: drift.Value(DateTime.utc(2026, 10, 12, 15, 0, 0)),
|
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 outbox = SyncOutbox(prefs);
|
||||||
final manager = SyncManager(
|
final manager = SyncManager(
|
||||||
outbox,
|
outbox,
|
||||||
initialProviders: [fakeProvider],
|
initialProviders: [fakeProvider],
|
||||||
defaultProviderId: 'firebase',
|
defaultProviderId: 'firebase',
|
||||||
db: db,
|
db: db,
|
||||||
authService: authService,
|
authService: authService,
|
||||||
);
|
);
|
||||||
|
|
||||||
final report = await manager.reconcileCompletedRides();
|
final report = await manager.reconcileCompletedRides();
|
||||||
expect(report.foreignOwnerSkippedCount, equals(1));
|
expect(report.foreignOwnerSkippedCount, equals(1));
|
||||||
expect(report.totalRecovered, equals(0));
|
expect(report.totalRecovered, equals(0));
|
||||||
expect(outbox.count, equals(0));
|
expect(outbox.count, equals(0));
|
||||||
|
|
||||||
// Ownership in db remains unchanged
|
// Ownership in db remains unchanged
|
||||||
expect(await db.getCloudOwnerUid(rideId), equals('rider_bob'));
|
expect(await db.getCloudOwnerUid(rideId), equals('rider_bob'));
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
test('6. In-Progress Isolation: Active rides without endTime are never recovered as completed', () async {
|
test(
|
||||||
authService.setAuthenticatedUser('rider_alice', 'alice@example.com');
|
'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)
|
// 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 rideId = await db.startRide(
|
||||||
|
startTime: DateTime.utc(2026, 10, 12, 16, 0, 0),
|
||||||
|
);
|
||||||
|
|
||||||
final outbox = SyncOutbox(prefs);
|
final outbox = SyncOutbox(prefs);
|
||||||
final manager = SyncManager(
|
final manager = SyncManager(
|
||||||
outbox,
|
outbox,
|
||||||
initialProviders: [fakeProvider],
|
initialProviders: [fakeProvider],
|
||||||
defaultProviderId: 'firebase',
|
defaultProviderId: 'firebase',
|
||||||
db: db,
|
db: db,
|
||||||
authService: authService,
|
authService: authService,
|
||||||
);
|
);
|
||||||
|
|
||||||
final report = await manager.reconcileCompletedRides();
|
final report = await manager.reconcileCompletedRides();
|
||||||
expect(report.discoveredCompletedRides, equals(0));
|
expect(report.discoveredCompletedRides, equals(0));
|
||||||
expect(report.totalRecovered, equals(0));
|
expect(report.totalRecovered, equals(0));
|
||||||
expect(outbox.getItem(rideId, 'firebase'), isNull);
|
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<void>.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);
|
||||||
|
},
|
||||||
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,12 +15,12 @@ class _FakeAuthService implements AuthService {
|
||||||
final AuthUser _user;
|
final AuthUser _user;
|
||||||
|
|
||||||
_FakeAuthService([String uid = 'pilot_pixel_8a'])
|
_FakeAuthService([String uid = 'pilot_pixel_8a'])
|
||||||
: _user = AuthUser(
|
: _user = AuthUser(
|
||||||
uid: uid,
|
uid: uid,
|
||||||
email: '$uid@example.com',
|
email: '$uid@example.com',
|
||||||
displayName: 'Pixel Pilot',
|
displayName: 'Pixel Pilot',
|
||||||
isAnonymous: false,
|
isAnonymous: false,
|
||||||
);
|
);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
AuthUser? get currentUser => _user;
|
AuthUser? get currentUser => _user;
|
||||||
|
|
@ -34,7 +34,11 @@ class _FakeAuthService implements AuthService {
|
||||||
bool get isEmailVerified => true;
|
bool get isEmailVerified => true;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> createUserWithEmailAndPassword(String email, String password, {String? displayName}) async {}
|
Future<void> createUserWithEmailAndPassword(
|
||||||
|
String email,
|
||||||
|
String password, {
|
||||||
|
String? displayName,
|
||||||
|
}) async {}
|
||||||
@override
|
@override
|
||||||
Future<void> deleteCurrentUser() async {}
|
Future<void> deleteCurrentUser() async {}
|
||||||
@override
|
@override
|
||||||
|
|
@ -54,7 +58,10 @@ class _FakeAuthService implements AuthService {
|
||||||
@override
|
@override
|
||||||
Future<void> signInAnonymously() async {}
|
Future<void> signInAnonymously() async {}
|
||||||
@override
|
@override
|
||||||
Future<void> signInWithEmailAndPassword(String email, String password) async {}
|
Future<void> signInWithEmailAndPassword(
|
||||||
|
String email,
|
||||||
|
String password,
|
||||||
|
) async {}
|
||||||
@override
|
@override
|
||||||
Future<void> signInWithGoogle() async {}
|
Future<void> signInWithGoogle() async {}
|
||||||
@override
|
@override
|
||||||
|
|
@ -69,22 +76,33 @@ void main() {
|
||||||
r'C:\Users\Bill\Desktop\flutter-app\output\master-consolidation-20261012\backup-071303\motorsignal.sqlite',
|
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
|
// 1. Verify original SHA-256 before copy
|
||||||
final originalBytes = await originalFile.readAsBytes();
|
final originalBytes = await originalFile.readAsBytes();
|
||||||
final originalHash = sha256.convert(originalBytes).toString();
|
final originalHash = sha256.convert(originalBytes).toString();
|
||||||
const expectedHash = '7125915195cdcf1de2d351426d8a991789904e7c3befd12af6f0d0f0b2416122';
|
const expectedHash =
|
||||||
expect(originalHash, equals(expectedHash), reason: 'Original backup must match authoritative SHA-256');
|
'7125915195cdcf1de2d351426d8a991789904e7c3befd12af6f0d0f0b2416122';
|
||||||
|
expect(
|
||||||
|
originalHash,
|
||||||
|
equals(expectedHash),
|
||||||
|
reason: 'Original backup must match authoritative SHA-256',
|
||||||
|
);
|
||||||
|
|
||||||
// 2. Create disposable copy in scratch directory
|
// 2. Create disposable copy in scratch directory
|
||||||
final scratchDir = 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()) {
|
if (!scratchDir.existsSync()) {
|
||||||
scratchDir.createSync(recursive: true);
|
scratchDir.createSync(recursive: true);
|
||||||
}
|
}
|
||||||
final disposableFile = File('${scratchDir.path}\\disposable_motorsignal.sqlite');
|
final disposableFile = File(
|
||||||
|
'${scratchDir.path}/disposable_motorsignal.sqlite',
|
||||||
|
);
|
||||||
if (disposableFile.existsSync()) {
|
if (disposableFile.existsSync()) {
|
||||||
disposableFile.deleteSync();
|
disposableFile.deleteSync();
|
||||||
}
|
}
|
||||||
|
|
@ -96,21 +114,39 @@ void main() {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Integrity check
|
// 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'];
|
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
|
// 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;
|
final totalRides = rideCountRows.first.data['count'] as int;
|
||||||
expect(totalRides, equals(22), reason: 'Must contain exactly 22 rides');
|
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 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;
|
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
|
// 4. Test Foreign Account Isolation against real Pixel backup
|
||||||
// In the real backup, rides are owned by Pixel rider '4btn4W3iOLWgfloMBYYK5hWa1hY2'
|
// In the real backup, rides are owned by Pixel rider '4btn4W3iOLWgfloMBYYK5hWa1hY2'
|
||||||
|
|
@ -133,7 +169,11 @@ void main() {
|
||||||
final foreignReport = await foreignSyncManager.reconcileCompletedRides();
|
final foreignReport = await foreignSyncManager.reconcileCompletedRides();
|
||||||
expect(foreignReport.errorCount, equals(0));
|
expect(foreignReport.errorCount, equals(0));
|
||||||
expect(foreignReport.discoveredCompletedRides, equals(completedRides));
|
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(foreignReport.totalRecovered, equals(0));
|
||||||
expect(outbox.count, equals(0));
|
expect(outbox.count, equals(0));
|
||||||
|
|
||||||
|
|
@ -160,7 +200,6 @@ void main() {
|
||||||
expect(report2.totalRecovered, equals(0));
|
expect(report2.totalRecovered, equals(0));
|
||||||
expect(report2.alreadyTrackedCount, equals(completedRides));
|
expect(report2.alreadyTrackedCount, equals(completedRides));
|
||||||
expect(outbox.count, equals(completedRides));
|
expect(outbox.count, equals(completedRides));
|
||||||
|
|
||||||
} finally {
|
} finally {
|
||||||
await db.close();
|
await db.close();
|
||||||
}
|
}
|
||||||
|
|
@ -168,6 +207,10 @@ void main() {
|
||||||
// 6. Verify original SHA-256 after operations to prove zero mutation
|
// 6. Verify original SHA-256 after operations to prove zero mutation
|
||||||
final postBytes = await originalFile.readAsBytes();
|
final postBytes = await originalFile.readAsBytes();
|
||||||
final postHash = sha256.convert(postBytes).toString();
|
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',
|
||||||
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -107,6 +107,10 @@ class _IsolatedContractSyncProvider implements SyncProvider {
|
||||||
@override
|
@override
|
||||||
Future<bool> isRideConfirmedInCloud(String remoteRideId) async => false;
|
Future<bool> isRideConfirmedInCloud(String remoteRideId) async => false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<CloudConfirmationResult> verifyCloudConfirmation(String remoteRideId) async =>
|
||||||
|
CloudConfirmationResult.missing(remoteRideId: remoteRideId);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<SyncResult> uploadRide(SyncItem item) async {
|
Future<SyncResult> uploadRide(SyncItem item) async {
|
||||||
if (!networkAvailable) {
|
if (!networkAvailable) {
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,10 @@ class _CountingProvider implements SyncProvider {
|
||||||
@override
|
@override
|
||||||
Future<bool> isRideConfirmedInCloud(String remoteRideId) async => false;
|
Future<bool> isRideConfirmedInCloud(String remoteRideId) async => false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<CloudConfirmationResult> verifyCloudConfirmation(String remoteRideId) async =>
|
||||||
|
CloudConfirmationResult.missing(remoteRideId: remoteRideId);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<SyncResult> uploadRide(SyncItem item) async {
|
Future<SyncResult> uploadRide(SyncItem item) async {
|
||||||
uploadCount++;
|
uploadCount++;
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,10 @@ class _R2ThenFirestoreProvider implements SyncProvider {
|
||||||
@override
|
@override
|
||||||
Future<bool> isRideConfirmedInCloud(String remoteRideId) async => false;
|
Future<bool> isRideConfirmedInCloud(String remoteRideId) async => false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<CloudConfirmationResult> verifyCloudConfirmation(String remoteRideId) async =>
|
||||||
|
CloudConfirmationResult.missing(remoteRideId: remoteRideId);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<SyncResult> uploadRide(SyncItem item) async {
|
Future<SyncResult> uploadRide(SyncItem item) async {
|
||||||
attempts++;
|
attempts++;
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,10 @@ class _ValidationProvider implements SyncProvider {
|
||||||
@override
|
@override
|
||||||
Future<bool> isRideConfirmedInCloud(String remoteRideId) async => false;
|
Future<bool> isRideConfirmedInCloud(String remoteRideId) async => false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<CloudConfirmationResult> verifyCloudConfirmation(String remoteRideId) async =>
|
||||||
|
CloudConfirmationResult.missing(remoteRideId: remoteRideId);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<SyncResult> uploadRide(SyncItem item) async {
|
Future<SyncResult> uploadRide(SyncItem item) async {
|
||||||
uploadCount++;
|
uploadCount++;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue