282 lines
10 KiB
Dart
282 lines
10 KiB
Dart
import 'dart:convert';
|
|
import 'dart:typed_data';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:shared_preferences/shared_preferences.dart';
|
|
import 'package:crypto/crypto.dart';
|
|
import 'package:http/http.dart' as http;
|
|
import 'package:http/testing.dart' as http_testing;
|
|
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/r2_upload_service.dart';
|
|
import 'package:motorcycle_dash/services/sync/sync_models.dart';
|
|
import 'package:motorcycle_dash/services/sync/sync_outbox.dart';
|
|
import 'package:motorcycle_dash/services/sync/sync_manager.dart';
|
|
import 'package:motorcycle_dash/services/sync/sync_provider.dart';
|
|
|
|
class MockSecurityAuthService implements AuthService {
|
|
@override bool get isEmailVerified => false;
|
|
@override Future<void> sendEmailVerification() async {}
|
|
@override Future<void> reloadCurrentUser() async {}
|
|
@override Future<void> reauthenticate({String? password}) async {}
|
|
@override Future<void> deleteCurrentUser() async {}
|
|
AuthUser? _user;
|
|
String? _token;
|
|
|
|
void setUser(AuthUser? user, {String? token}) {
|
|
_user = user;
|
|
_token = token ?? (user != null ? 'jwt_token_${user.uid}' : null);
|
|
}
|
|
|
|
@override
|
|
bool get isConfigured => true;
|
|
|
|
@override
|
|
AuthUser? get currentUser => _user;
|
|
|
|
@override
|
|
AuthState get currentState => _user == null
|
|
? const AuthState.unauthenticated()
|
|
: (_user!.isAnonymous ? AuthState.anonymous(_user!) : AuthState.authenticated(_user!));
|
|
|
|
@override
|
|
Stream<AuthState> get authStateChanges => Stream.value(currentState);
|
|
|
|
@override
|
|
Future<String?> getIdToken([bool forceRefresh = false]) async => _token;
|
|
|
|
@override
|
|
Future<void> createUserWithEmailAndPassword(String email, String password, {String? displayName}) async {}
|
|
|
|
@override
|
|
Future<void> sendPasswordResetEmail(String email) async {}
|
|
|
|
@override
|
|
Future<void> signInAnonymously() async {
|
|
setUser(const AuthUser(uid: 'anon_rider_777', isAnonymous: true));
|
|
}
|
|
|
|
@override
|
|
Future<void> signInWithEmailAndPassword(String email, String password) async {
|
|
setUser(AuthUser(uid: 'authenticated_pilot_888', email: email));
|
|
}
|
|
|
|
@override
|
|
Future<void> signInWithGoogle() async {
|
|
setUser(const AuthUser(uid: 'google_rider_999', email: 'google@test.com'));
|
|
}
|
|
|
|
@override
|
|
Future<void> signOut() async {
|
|
_user = null;
|
|
_token = null;
|
|
}
|
|
|
|
@override
|
|
void dispose() {}
|
|
}
|
|
|
|
void main() {
|
|
TestWidgetsFlutterBinding.ensureInitialized();
|
|
|
|
group('MotorSignal Phase 2E — Cloud Sync Security & R2 Gateway Audit Tests', () {
|
|
late SharedPreferences prefs;
|
|
late MockSecurityAuthService authService;
|
|
|
|
setUp(() async {
|
|
SharedPreferences.setMockInitialValues({});
|
|
prefs = await SharedPreferences.getInstance();
|
|
authService = MockSecurityAuthService();
|
|
});
|
|
|
|
// 1. Valid Firebase ID Token Flow
|
|
test('Security 1: Valid Firebase JWT ID token is transmitted in Bearer header', () async {
|
|
authService.setUser(
|
|
const AuthUser(uid: 'pilot_alpha', email: 'pilot@apex.com'),
|
|
token: 'eyJhbGciOiJSUzI1NiIsImtpZCI6IjEyMyJ9.eyJpc3MiOiJodHRwczovL3NlY3VyZXRva2VuLmdvb2dsZS5jb20vbW90b2FwcC1kMjJlMCIsImF1ZCI6Im1vdG9hcHAtZDIyZTAiLCJzdWIiOiJwaWxvdF9hbHBoYSIsImV4cCI6OTk5OTk5OTk5OX0.dummy_sig',
|
|
);
|
|
|
|
final token = await authService.getIdToken();
|
|
expect(token, isNotNull);
|
|
expect(token!.startsWith('eyJ'), isTrue);
|
|
|
|
final mockClient = http_testing.MockClient((request) async {
|
|
expect(request.headers['Authorization'], equals('Bearer $token'));
|
|
expect(request.headers['X-Remote-Ride-Id'], equals('ride_42_20260831_120000'));
|
|
expect(request.headers['X-Bundle-Sha256'], equals('valid_sha256'));
|
|
expect(request.headers['X-App-Version'], equals('1.0.2'));
|
|
|
|
return http.Response(
|
|
jsonEncode({
|
|
'success': true,
|
|
'objectKey': 'users/pilot_alpha/rides/ride_42_20260831_120000/telemetry.zip',
|
|
'size': 2048,
|
|
}),
|
|
200,
|
|
);
|
|
});
|
|
|
|
final service = R2UploadService(client: mockClient);
|
|
final result = await service.uploadTelemetryBundle(
|
|
idToken: token,
|
|
remoteRideId: 'ride_42_20260831_120000',
|
|
zipBytes: Uint8List.fromList([1, 2, 3, 4, 5]),
|
|
sha256Hash: 'valid_sha256',
|
|
appVersion: '1.0.2',
|
|
);
|
|
|
|
expect(result.success, isTrue);
|
|
expect(result.objectKey, equals('users/pilot_alpha/rides/ride_42_20260831_120000/telemetry.zip'));
|
|
});
|
|
|
|
// 2. Missing / Malformed Authorization Header Handling
|
|
test('Security 2: Missing or malformed token returns 401 and transitions to authRequired', () async {
|
|
final mockClient = http_testing.MockClient((request) async {
|
|
return http.Response(
|
|
jsonEncode({'error': 'Missing or malformed Authorization header'}),
|
|
401,
|
|
);
|
|
});
|
|
|
|
final service = R2UploadService(client: mockClient);
|
|
final result = await service.uploadTelemetryBundle(
|
|
idToken: '',
|
|
remoteRideId: 'ride_1_20260831_120000',
|
|
zipBytes: Uint8List.fromList([1, 2]),
|
|
sha256Hash: 'dummy',
|
|
appVersion: '1.0.2',
|
|
);
|
|
|
|
expect(result.success, isFalse);
|
|
expect(result.requiresAuth, isTrue);
|
|
expect(result.statusCode, equals(401));
|
|
});
|
|
|
|
// 3. Expired or Invalid JWT Token
|
|
test('Security 3: Expired or invalid token returns 401 authRequired', () async {
|
|
final mockClient = http_testing.MockClient((request) async {
|
|
return http.Response(
|
|
jsonEncode({'error': 'Invalid or expired Firebase authentication token'}),
|
|
401,
|
|
);
|
|
});
|
|
|
|
final service = R2UploadService(client: mockClient);
|
|
final result = await service.uploadTelemetryBundle(
|
|
idToken: 'expired.jwt.token',
|
|
remoteRideId: 'ride_1_20260831_120000',
|
|
zipBytes: Uint8List.fromList([1, 2]),
|
|
sha256Hash: 'dummy',
|
|
appVersion: '1.0.2',
|
|
);
|
|
|
|
expect(result.success, isFalse);
|
|
expect(result.requiresAuth, isTrue);
|
|
});
|
|
|
|
// 4. Malformed Remote Ride ID / Path Traversal Attack Rejection
|
|
test('Security 4: Malicious remoteRideId is rejected as HTTP 400 permanent failure', () async {
|
|
final mockClient = http_testing.MockClient((request) async {
|
|
final rideId = request.headers['X-Remote-Ride-Id'];
|
|
if (rideId == null || rideId.contains('..') || rideId.contains('/')) {
|
|
return http.Response(jsonEncode({'error': 'Invalid or malicious ride ID'}), 400);
|
|
}
|
|
return http.Response('OK', 200);
|
|
});
|
|
|
|
final service = R2UploadService(client: mockClient);
|
|
final result = await service.uploadTelemetryBundle(
|
|
idToken: 'valid_token',
|
|
remoteRideId: '../../users/victim/rides/ride_1',
|
|
zipBytes: Uint8List.fromList([1, 2]),
|
|
sha256Hash: 'dummy',
|
|
appVersion: '1.0.2',
|
|
);
|
|
|
|
expect(result.success, isFalse);
|
|
expect(result.isRetryable, isFalse);
|
|
expect(result.statusCode, equals(400));
|
|
});
|
|
|
|
// 5. Cloudflare R2 / Worker 5xx Temporary Server Failure
|
|
test('Security 5: Server 500 error classifies as retryable temporary failure', () async {
|
|
final mockClient = http_testing.MockClient((request) async {
|
|
return http.Response('Cloudflare Worker Exception', 500);
|
|
});
|
|
|
|
final service = R2UploadService(client: mockClient);
|
|
final result = await service.uploadTelemetryBundle(
|
|
idToken: 'valid_token',
|
|
remoteRideId: 'ride_5_20260831_120000',
|
|
zipBytes: Uint8List.fromList([1, 2]),
|
|
sha256Hash: 'dummy',
|
|
appVersion: '1.0.2',
|
|
);
|
|
|
|
expect(result.success, isFalse);
|
|
expect(result.isRetryable, isTrue);
|
|
expect(result.requiresAuth, isFalse);
|
|
});
|
|
|
|
// 6. Network Timeout Handling
|
|
test('Security 6: Gateway timeout classifies as retryable temporary failure', () async {
|
|
final mockClient = http_testing.MockClient((request) async {
|
|
return http.Response('Gateway Timeout', 504);
|
|
});
|
|
|
|
final service = R2UploadService(client: mockClient);
|
|
final result = await service.uploadTelemetryBundle(
|
|
idToken: 'valid_token',
|
|
remoteRideId: 'ride_6_20260831_120000',
|
|
zipBytes: Uint8List.fromList([1, 2]),
|
|
sha256Hash: 'dummy',
|
|
appVersion: '1.0.2',
|
|
);
|
|
|
|
expect(result.success, isFalse);
|
|
expect(result.isRetryable, isTrue);
|
|
});
|
|
|
|
// 7. SHA-256 Exact Byte Integrity Verification
|
|
test('Security 7: SHA-256 is computed accurately on exact ZIP bytes', () {
|
|
final testBytes = utf8.encode('MOTORSIGNAL_20HZ_TELEMETRY_FORENSIC_BUNDLE');
|
|
final expectedSha = sha256.convert(testBytes).toString();
|
|
|
|
final computed = sha256.convert(Uint8List.fromList(testBytes)).toString();
|
|
expect(computed, equals(expectedSha));
|
|
expect(computed.length, equals(64));
|
|
});
|
|
|
|
// 8. Outbox Composite Key Isolation
|
|
test('Security 8: Outbox preserves provider-scoped composite idempotency key', () async {
|
|
final outbox = SyncOutbox(prefs);
|
|
|
|
await outbox.enqueue(501, 'firebase');
|
|
await outbox.enqueue(501, 'github');
|
|
|
|
expect(outbox.count, equals(2));
|
|
expect(outbox.getItem(501, 'firebase'), isNotNull);
|
|
expect(outbox.getItem(501, 'github'), isNotNull);
|
|
expect(outbox.getItem(501, 'firebase')!.key, equals('firebase:501'));
|
|
expect(outbox.getItem(501, 'github')!.key, equals('github:501'));
|
|
expect(outbox.getItem(501, 'firebase')!.key, isNot(equals(outbox.getItem(501, 'github')!.key)));
|
|
});
|
|
|
|
// 9. Multi-Provider SyncManager Resilience
|
|
test('Security 9: Provider failure does not contaminate other providers', () async {
|
|
final outbox = SyncOutbox(prefs);
|
|
final p1 = FakeSyncProvider(providerId: 'firebase', behavior: FakeSyncBehavior.temporaryFailure);
|
|
final p2 = FakeSyncProvider(providerId: 'github', behavior: FakeSyncBehavior.success);
|
|
|
|
final manager = SyncManager(outbox, initialProviders: [p1, p2]);
|
|
await manager.enqueueRide(502, targetProviderId: 'firebase', autoProcess: false);
|
|
await manager.enqueueRide(502, targetProviderId: 'github', autoProcess: false);
|
|
|
|
await manager.processQueue();
|
|
|
|
expect(outbox.getItem(502, 'firebase')!.status, equals(SyncStatus.retryWait));
|
|
expect(outbox.getItem(502, 'github')!.status, equals(SyncStatus.synced));
|
|
});
|
|
});
|
|
}
|