1109 lines
35 KiB
Dart
1109 lines
35 KiB
Dart
import 'dart:async';
|
|
import 'dart:convert';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:fake_cloud_firestore/fake_cloud_firestore.dart';
|
|
import 'package:drift/native.dart';
|
|
import 'package:http/http.dart' as http;
|
|
import 'package:http/testing.dart' as http_testing;
|
|
import 'package:motorcycle_dash/database/database.dart';
|
|
import 'package:motorcycle_dash/services/auth/auth_service.dart';
|
|
import 'package:motorcycle_dash/services/auth/auth_state.dart';
|
|
import 'package:motorcycle_dash/services/auth/auth_user.dart';
|
|
import 'package:motorcycle_dash/services/sync/firebase_sync_provider.dart';
|
|
import 'package:motorcycle_dash/services/sync/r2_upload_service.dart';
|
|
import 'package:motorcycle_dash/services/sync/sync_provider.dart';
|
|
import 'package:motorcycle_dash/services/sync/sync_models.dart';
|
|
|
|
class _MockAuthService implements AuthService {
|
|
AuthUser? _currentUser;
|
|
AuthState _authState = const AuthState.unauthenticated();
|
|
String? idTokenToReturn = 'valid_mock_jwt_token';
|
|
String? refreshedIdToken;
|
|
int getIdTokenCalls = 0;
|
|
|
|
void setAuthenticated(String uid, String email) {
|
|
_currentUser = AuthUser(uid: uid, email: email, isAnonymous: false);
|
|
_authState = AuthState.authenticated(_currentUser!);
|
|
}
|
|
|
|
void setGuest() {
|
|
_currentUser = const AuthUser(uid: 'guest_uid', isAnonymous: true);
|
|
_authState = AuthState.anonymous(_currentUser!);
|
|
}
|
|
|
|
void setSignedOut() {
|
|
_currentUser = null;
|
|
_authState = const AuthState.unauthenticated();
|
|
}
|
|
|
|
@override
|
|
AuthUser? get currentUser => _currentUser;
|
|
@override
|
|
AuthState get currentState => _authState;
|
|
@override
|
|
Stream<AuthState> get authStateChanges => Stream.value(_authState);
|
|
@override
|
|
bool get isConfigured => true;
|
|
@override
|
|
bool get isEmailVerified => true;
|
|
@override
|
|
Future<String?> getIdToken([bool forceRefresh = false]) async {
|
|
getIdTokenCalls++;
|
|
if (forceRefresh && refreshedIdToken != null) {
|
|
return refreshedIdToken;
|
|
}
|
|
return 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.legacyUnverified));
|
|
expect(result.isLegacyUnverified, isTrue);
|
|
},
|
|
);
|
|
|
|
test(
|
|
'14. Edge Routing: Non-JSON HTML 404 from proxy returns temporarilyUnverifiable instead of missing',
|
|
() async {
|
|
await fakeFirestore
|
|
.collection('users')
|
|
.doc(testUid)
|
|
.collection('rides')
|
|
.doc(remoteRideId)
|
|
.set(createFirestoreRideData());
|
|
|
|
final mockClient = http_testing.MockClient((request) async {
|
|
return http.Response(
|
|
'<html><head><title>404 Not Found</title></head><body><h1>404 Not Found</h1>cloudflare</body></html>',
|
|
404,
|
|
);
|
|
});
|
|
|
|
final r2Service = R2UploadService(client: mockClient);
|
|
final provider = FirebaseSyncProvider(
|
|
db: db,
|
|
authService: authService,
|
|
firestore: fakeFirestore,
|
|
r2UploadService: r2Service,
|
|
);
|
|
|
|
final result = await provider.verifyCloudConfirmation(remoteRideId);
|
|
expect(
|
|
result.status,
|
|
equals(CloudConfirmationStatus.temporarilyUnverifiable),
|
|
);
|
|
},
|
|
);
|
|
|
|
test(
|
|
'15. uploadRide Protection: Aborts with permanentFailure when cloud confirmation is inconsistent (never overwrites object)',
|
|
() async {
|
|
final rideId = await db.startRide(
|
|
startTime: DateTime.utc(2026, 10, 12, 10, 0, 0),
|
|
);
|
|
await db.endRide(rideId, const RidesCompanion());
|
|
await db.claimRideForCloudOwner(rideId, testUid);
|
|
final actualRemoteRideId = await db.ensureCloudRideId(rideId);
|
|
|
|
// Firestore document exists
|
|
await fakeFirestore
|
|
.collection('users')
|
|
.doc(testUid)
|
|
.collection('rides')
|
|
.doc(actualRemoteRideId)
|
|
.set(createFirestoreRideData(rideId: actualRemoteRideId));
|
|
|
|
var uploadAttempted = false;
|
|
final mockClient = http_testing.MockClient((request) async {
|
|
if (request.url.path == '/r2/verify') {
|
|
// Return inconsistent hash
|
|
return http.Response(
|
|
jsonEncode({
|
|
'verified': true,
|
|
'remoteRideId': actualRemoteRideId,
|
|
'objectKey':
|
|
'users/$testUid/rides/$actualRemoteRideId/telemetry.zip',
|
|
'size': validSize,
|
|
'sha256': 'corrupted_hash_mismatch_123',
|
|
}),
|
|
200,
|
|
);
|
|
}
|
|
if (request.url.path == '/r2/upload') {
|
|
uploadAttempted = true;
|
|
return http.Response('{}', 200);
|
|
}
|
|
return http.Response('{}', 404);
|
|
});
|
|
|
|
final r2Service = R2UploadService(client: mockClient);
|
|
final provider = FirebaseSyncProvider(
|
|
db: db,
|
|
authService: authService,
|
|
firestore: fakeFirestore,
|
|
r2UploadService: r2Service,
|
|
);
|
|
|
|
final result = await provider.uploadRide(
|
|
SyncItem(
|
|
localRideId: rideId,
|
|
providerId: 'firebase',
|
|
createdAt: DateTime.now(),
|
|
),
|
|
);
|
|
|
|
expect(result.success, isFalse);
|
|
expect(result.isRetryable, isFalse);
|
|
expect(result.errorMessage, contains('inconsistent'));
|
|
expect(
|
|
uploadAttempted,
|
|
isFalse,
|
|
reason:
|
|
'Must NEVER attempt R2 upload when cloud data is inconsistent',
|
|
);
|
|
},
|
|
);
|
|
|
|
test(
|
|
'16. uploadRide Protection: Defers with temporaryFailure when cloud verification is temporarilyUnverifiable (no heavy upload)',
|
|
() async {
|
|
final rideId = await db.startRide(
|
|
startTime: DateTime.utc(2026, 10, 12, 10, 0, 0),
|
|
);
|
|
await db.endRide(rideId, const RidesCompanion());
|
|
await db.claimRideForCloudOwner(rideId, testUid);
|
|
final actualRemoteRideId = await db.ensureCloudRideId(rideId);
|
|
|
|
await fakeFirestore
|
|
.collection('users')
|
|
.doc(testUid)
|
|
.collection('rides')
|
|
.doc(actualRemoteRideId)
|
|
.set(createFirestoreRideData(rideId: actualRemoteRideId));
|
|
|
|
var uploadAttempted = false;
|
|
final mockClient = http_testing.MockClient((request) async {
|
|
if (request.url.path == '/r2/verify') {
|
|
return http.Response('Rate limit exceeded', 429);
|
|
}
|
|
if (request.url.path == '/r2/upload') {
|
|
uploadAttempted = true;
|
|
return http.Response('{}', 200);
|
|
}
|
|
return http.Response('{}', 404);
|
|
});
|
|
|
|
final r2Service = R2UploadService(client: mockClient);
|
|
final provider = FirebaseSyncProvider(
|
|
db: db,
|
|
authService: authService,
|
|
firestore: fakeFirestore,
|
|
r2UploadService: r2Service,
|
|
);
|
|
|
|
final result = await provider.uploadRide(
|
|
SyncItem(
|
|
localRideId: rideId,
|
|
providerId: 'firebase',
|
|
createdAt: DateTime.now(),
|
|
),
|
|
);
|
|
|
|
expect(result.success, isFalse);
|
|
expect(result.isRetryable, isTrue);
|
|
expect(result.errorMessage, contains('temporarily unavailable'));
|
|
expect(
|
|
uploadAttempted,
|
|
isFalse,
|
|
reason: 'Must NOT upload bundle when verification is rate limited',
|
|
);
|
|
},
|
|
);
|
|
|
|
test(
|
|
'17. uploadRide Protection: Returns authRequired when cloud verification is unauthorized',
|
|
() async {
|
|
final rideId = await db.startRide(
|
|
startTime: DateTime.utc(2026, 10, 12, 10, 0, 0),
|
|
);
|
|
await db.endRide(rideId, const RidesCompanion());
|
|
await db.claimRideForCloudOwner(rideId, testUid);
|
|
final actualRemoteRideId = await db.ensureCloudRideId(rideId);
|
|
|
|
await fakeFirestore
|
|
.collection('users')
|
|
.doc(testUid)
|
|
.collection('rides')
|
|
.doc(actualRemoteRideId)
|
|
.set(
|
|
createFirestoreRideData(
|
|
ownerId: 'different_owner_uid',
|
|
rideId: actualRemoteRideId,
|
|
),
|
|
);
|
|
|
|
var uploadAttempted = false;
|
|
final mockClient = http_testing.MockClient((request) async {
|
|
if (request.url.path == '/r2/upload') {
|
|
uploadAttempted = true;
|
|
}
|
|
return http.Response('{}', 200);
|
|
});
|
|
|
|
final r2Service = R2UploadService(client: mockClient);
|
|
final provider = FirebaseSyncProvider(
|
|
db: db,
|
|
authService: authService,
|
|
firestore: fakeFirestore,
|
|
r2UploadService: r2Service,
|
|
);
|
|
|
|
final result = await provider.uploadRide(
|
|
SyncItem(
|
|
localRideId: rideId,
|
|
providerId: 'firebase',
|
|
createdAt: DateTime.now(),
|
|
),
|
|
);
|
|
|
|
expect(result.success, isFalse);
|
|
expect(result.requiresAuth, isTrue);
|
|
expect(uploadAttempted, isFalse);
|
|
},
|
|
);
|
|
|
|
test(
|
|
'18. Modern bundle (syncVersion >= 3) missing required sha256 returns inconsistent',
|
|
() async {
|
|
await fakeFirestore
|
|
.collection('users')
|
|
.doc(testUid)
|
|
.collection('rides')
|
|
.doc(remoteRideId)
|
|
.set({
|
|
'id': remoteRideId,
|
|
'ownerId': testUid,
|
|
'syncVersion': 3,
|
|
'bundleSizeBytes': 2048,
|
|
'telemetryBundle': {
|
|
'provider': 'cloudflare-r2',
|
|
'objectKey': 'users/$testUid/rides/$remoteRideId/telemetry.zip',
|
|
'status': 'uploaded',
|
|
'bundleSizeBytes': 2048,
|
|
},
|
|
});
|
|
|
|
final mockClient = http_testing.MockClient(
|
|
(request) async => http.Response('{}', 200),
|
|
);
|
|
final r2Service = R2UploadService(client: mockClient);
|
|
final provider = FirebaseSyncProvider(
|
|
db: db,
|
|
authService: authService,
|
|
firestore: fakeFirestore,
|
|
r2UploadService: r2Service,
|
|
);
|
|
|
|
final result = await provider.verifyCloudConfirmation(remoteRideId);
|
|
expect(result.status, equals(CloudConfirmationStatus.inconsistent));
|
|
expect(result.isInconsistent, isTrue);
|
|
expect(result.message, contains('missing required sha256'));
|
|
},
|
|
);
|
|
|
|
test(
|
|
'19. Modern bundle (syncVersion >= 3) with non-positive bundleSizeBytes returns inconsistent',
|
|
() async {
|
|
await fakeFirestore
|
|
.collection('users')
|
|
.doc(testUid)
|
|
.collection('rides')
|
|
.doc(remoteRideId)
|
|
.set({
|
|
'id': remoteRideId,
|
|
'ownerId': testUid,
|
|
'syncVersion': 3,
|
|
'bundleSizeBytes': 0,
|
|
'sha256': validSha256,
|
|
'telemetryBundle': {
|
|
'provider': 'cloudflare-r2',
|
|
'objectKey': 'users/$testUid/rides/$remoteRideId/telemetry.zip',
|
|
'status': 'uploaded',
|
|
'bundleSizeBytes': 0,
|
|
'sha256': validSha256,
|
|
},
|
|
});
|
|
|
|
final mockClient = http_testing.MockClient(
|
|
(request) async => http.Response('{}', 200),
|
|
);
|
|
final r2Service = R2UploadService(client: mockClient);
|
|
final provider = FirebaseSyncProvider(
|
|
db: db,
|
|
authService: authService,
|
|
firestore: fakeFirestore,
|
|
r2UploadService: r2Service,
|
|
);
|
|
|
|
final result = await provider.verifyCloudConfirmation(remoteRideId);
|
|
expect(result.status, equals(CloudConfirmationStatus.inconsistent));
|
|
expect(result.isInconsistent, isTrue);
|
|
expect(result.message, contains('invalid bundleSizeBytes'));
|
|
},
|
|
);
|
|
|
|
test(
|
|
'20. R2 Worker response with invalid objectKey pattern returns inconsistent',
|
|
() async {
|
|
await fakeFirestore
|
|
.collection('users')
|
|
.doc(testUid)
|
|
.collection('rides')
|
|
.doc(remoteRideId)
|
|
.set(createFirestoreRideData());
|
|
|
|
final mockClient = http_testing.MockClient((request) async {
|
|
return http.Response(
|
|
jsonEncode({
|
|
'verified': true,
|
|
'remoteRideId': remoteRideId,
|
|
'objectKey': 'wrong_path/telemetry.zip',
|
|
'size': validSize,
|
|
'sha256': validSha256,
|
|
}),
|
|
200,
|
|
);
|
|
});
|
|
|
|
final r2Service = R2UploadService(client: mockClient);
|
|
final provider = FirebaseSyncProvider(
|
|
db: db,
|
|
authService: authService,
|
|
firestore: fakeFirestore,
|
|
r2UploadService: r2Service,
|
|
);
|
|
|
|
final result = await provider.verifyCloudConfirmation(remoteRideId);
|
|
expect(result.status, equals(CloudConfirmationStatus.inconsistent));
|
|
expect(result.message, contains('objectKey'));
|
|
},
|
|
);
|
|
|
|
test(
|
|
'21. R2 Worker response with non-positive size returns inconsistent',
|
|
() async {
|
|
await fakeFirestore
|
|
.collection('users')
|
|
.doc(testUid)
|
|
.collection('rides')
|
|
.doc(remoteRideId)
|
|
.set(createFirestoreRideData());
|
|
|
|
final mockClient = http_testing.MockClient((request) async {
|
|
return http.Response(
|
|
jsonEncode({
|
|
'verified': true,
|
|
'remoteRideId': remoteRideId,
|
|
'objectKey': 'users/$testUid/rides/$remoteRideId/telemetry.zip',
|
|
'size': 0,
|
|
'sha256': validSha256,
|
|
}),
|
|
200,
|
|
);
|
|
});
|
|
|
|
final r2Service = R2UploadService(client: mockClient);
|
|
final provider = FirebaseSyncProvider(
|
|
db: db,
|
|
authService: authService,
|
|
firestore: fakeFirestore,
|
|
r2UploadService: r2Service,
|
|
);
|
|
|
|
final result = await provider.verifyCloudConfirmation(remoteRideId);
|
|
expect(result.status, equals(CloudConfirmationStatus.inconsistent));
|
|
expect(result.message, contains('size'));
|
|
},
|
|
);
|
|
|
|
test(
|
|
'22. uploadRide Protection: Legacy unverified ride confirms without re-uploading bundle',
|
|
() async {
|
|
final rideId = await db.startRide(
|
|
startTime: DateTime.utc(2026, 10, 12, 10, 0, 0),
|
|
);
|
|
await db.endRide(rideId, const RidesCompanion());
|
|
await db.claimRideForCloudOwner(rideId, testUid);
|
|
final actualRemoteRideId = await db.ensureCloudRideId(rideId);
|
|
|
|
// Legacy Firestore record without sha256
|
|
await fakeFirestore
|
|
.collection('users')
|
|
.doc(testUid)
|
|
.collection('rides')
|
|
.doc(actualRemoteRideId)
|
|
.set({
|
|
'id': actualRemoteRideId,
|
|
'ownerId': testUid,
|
|
'telemetryBundle': {
|
|
'provider': 'cloudflare-r2',
|
|
'objectKey':
|
|
'users/$testUid/rides/$actualRemoteRideId/telemetry.zip',
|
|
'status': 'uploaded',
|
|
},
|
|
});
|
|
|
|
var uploadAttempted = false;
|
|
final mockClient = http_testing.MockClient((request) async {
|
|
if (request.url.path == '/r2/verify') {
|
|
return http.Response(
|
|
jsonEncode({
|
|
'verified': true,
|
|
'remoteRideId': actualRemoteRideId,
|
|
'objectKey':
|
|
'users/$testUid/rides/$actualRemoteRideId/telemetry.zip',
|
|
'size': 1024,
|
|
'sha256': 'legacy_hash',
|
|
}),
|
|
200,
|
|
);
|
|
}
|
|
if (request.url.path == '/r2/upload') {
|
|
uploadAttempted = true;
|
|
}
|
|
return http.Response('{}', 200);
|
|
});
|
|
|
|
final r2Service = R2UploadService(client: mockClient);
|
|
final provider = FirebaseSyncProvider(
|
|
db: db,
|
|
authService: authService,
|
|
firestore: fakeFirestore,
|
|
r2UploadService: r2Service,
|
|
);
|
|
|
|
final result = await provider.uploadRide(
|
|
SyncItem(
|
|
localRideId: rideId,
|
|
providerId: 'firebase',
|
|
createdAt: DateTime.now(),
|
|
),
|
|
);
|
|
|
|
expect(result.success, isTrue);
|
|
expect(result.remoteId, equals(actualRemoteRideId));
|
|
expect(
|
|
uploadAttempted,
|
|
isFalse,
|
|
reason: 'Must not re-upload existing historical bundle',
|
|
);
|
|
},
|
|
);
|
|
|
|
test(
|
|
'23. Expired Firebase token triggers forceRefresh and confirms on retry',
|
|
() async {
|
|
await fakeFirestore
|
|
.collection('users')
|
|
.doc(testUid)
|
|
.collection('rides')
|
|
.doc(remoteRideId)
|
|
.set(createFirestoreRideData());
|
|
|
|
authService.idTokenToReturn = 'expired_old_token';
|
|
authService.refreshedIdToken = 'fresh_valid_jwt_token';
|
|
|
|
var callCount = 0;
|
|
final mockClient = http_testing.MockClient((request) async {
|
|
callCount++;
|
|
final authHeader = request.headers['Authorization'];
|
|
if (authHeader == 'Bearer expired_old_token') {
|
|
return http.Response(
|
|
jsonEncode({'error': 'Token expired', 'code': 'UNAUTHORIZED'}),
|
|
401,
|
|
);
|
|
}
|
|
if (authHeader == 'Bearer fresh_valid_jwt_token') {
|
|
return http.Response(
|
|
jsonEncode({
|
|
'verified': true,
|
|
'remoteRideId': remoteRideId,
|
|
'objectKey': 'users/$testUid/rides/$remoteRideId/telemetry.zip',
|
|
'size': validSize,
|
|
'sha256': validSha256,
|
|
}),
|
|
200,
|
|
);
|
|
}
|
|
return http.Response('{}', 403);
|
|
});
|
|
|
|
final r2Service = R2UploadService(client: mockClient);
|
|
final provider = FirebaseSyncProvider(
|
|
db: db,
|
|
authService: authService,
|
|
firestore: fakeFirestore,
|
|
r2UploadService: r2Service,
|
|
);
|
|
|
|
final result = await provider.verifyCloudConfirmation(remoteRideId);
|
|
expect(result.status, equals(CloudConfirmationStatus.confirmed));
|
|
expect(result.isConfirmed, isTrue);
|
|
expect(
|
|
callCount,
|
|
equals(2),
|
|
reason: 'Should have retried once with refreshed token',
|
|
);
|
|
},
|
|
);
|
|
|
|
test(
|
|
'24. Worker returns 503 TEMPORARY_ERROR when Firestore temporarily fails (maps to temporarilyUnverifiable)',
|
|
() async {
|
|
await fakeFirestore
|
|
.collection('users')
|
|
.doc(testUid)
|
|
.collection('rides')
|
|
.doc(remoteRideId)
|
|
.set(createFirestoreRideData());
|
|
|
|
final mockClient = http_testing.MockClient((request) async {
|
|
return http.Response(
|
|
jsonEncode({
|
|
'error': 'Firestore temporary lookup error',
|
|
'code': 'TEMPORARY_ERROR',
|
|
}),
|
|
503,
|
|
);
|
|
});
|
|
|
|
final r2Service = R2UploadService(client: mockClient);
|
|
final provider = FirebaseSyncProvider(
|
|
db: db,
|
|
authService: authService,
|
|
firestore: fakeFirestore,
|
|
r2UploadService: r2Service,
|
|
);
|
|
|
|
final result = await provider.verifyCloudConfirmation(remoteRideId);
|
|
expect(
|
|
result.status,
|
|
equals(CloudConfirmationStatus.temporarilyUnverifiable),
|
|
);
|
|
expect(result.isConfirmed, isFalse);
|
|
},
|
|
);
|
|
});
|
|
}
|