MotorSignal-App/test/r2_security_audit_test.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));
});
});
}