MotorSignal-App/test/cloud_confirmation_contract...

567 lines
18 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';
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));
},
);
});
}