diff --git a/test/sync_architecture_hardening_test.dart b/test/sync_architecture_hardening_test.dart new file mode 100644 index 0000000..5335be6 --- /dev/null +++ b/test/sync_architecture_hardening_test.dart @@ -0,0 +1,110 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:shared_preferences/shared_preferences.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'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('Sync outbox hardening', () { + test('interrupted uploading item is requeued after restart', () async { + SharedPreferences.setMockInitialValues({ + 'motorsignal_sync_outbox_manifest_v1': jsonEncode([ + SyncItem( + localRideId: 42, + providerId: 'firebase', + status: SyncStatus.uploading, + createdAt: DateTime(2026, 9, 1), + ).toJson(), + ]), + }); + final prefs = await SharedPreferences.getInstance(); + final outbox = SyncOutbox(prefs); + expect(outbox.getItem(42, 'firebase')?.status, SyncStatus.queued); + }); + + test('duplicate enqueue remains one logical provider/ride item', () async { + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + final outbox = SyncOutbox(prefs); + await Future.wait([ + outbox.enqueue(7, 'firebase'), + outbox.enqueue(7, 'firebase'), + ]); + expect(outbox.count, 1); + expect(outbox.getItem(7, 'firebase')?.idempotencyKey, 'firebase:7'); + }); + + test('malformed persisted identity is ignored without killing the queue', () async { + SharedPreferences.setMockInitialValues({ + 'motorsignal_sync_outbox_manifest_v1': jsonEncode([ + {'localRideId': -1, 'providerId': '', 'status': 'queued', 'createdAt': 'bad'}, + SyncItem(localRideId: 8, providerId: 'firebase', createdAt: DateTime(2026, 9, 1)).toJson(), + ]), + }); + final prefs = await SharedPreferences.getInstance(); + final outbox = SyncOutbox(prefs); + expect(outbox.count, 1); + expect(outbox.getItem(8, 'firebase'), isNotNull); + }); + }); + + group('R2 gateway hardening', () { + const validHash = '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef'; + + test('valid response requires a user-scoped object key', () async { + final client = MockClient((request) async => http.Response( + jsonEncode({'objectKey': 'users/u1/rides/ride_1_20260901_120000/telemetry.zip', 'size': 4}), + 200, + )); + final service = R2UploadService(client: client, workerBaseUrl: 'https://worker.test'); + final result = await service.uploadTelemetryBundle( + idToken: 'token', + remoteRideId: 'ride_1_20260901_120000', + zipBytes: Uint8List.fromList([1, 2, 3, 4]), + sha256Hash: validHash, + appVersion: '1.0.2', + ); + expect(result.success, isTrue); + }); + + test('invalid identifiers are rejected before network I/O', () async { + var requests = 0; + final client = MockClient((request) async { + requests++; + return http.Response('{}', 200); + }); + final service = R2UploadService(client: client, workerBaseUrl: 'https://worker.test'); + final result = await service.uploadTelemetryBundle( + idToken: 'token', + remoteRideId: '../escape', + zipBytes: Uint8List.fromList([1]), + sha256Hash: validHash, + appVersion: '1.0.2', + ); + expect(result.success, isFalse); + expect(result.isRetryable, isFalse); + expect(requests, 0); + }); + + test('rate limiting is retryable and delegated to SyncManager backoff', () async { + final client = MockClient((request) async => http.Response('rate limited', 429)); + final service = R2UploadService(client: client, workerBaseUrl: 'https://worker.test'); + final result = await service.uploadTelemetryBundle( + idToken: 'token', + remoteRideId: 'ride_1_20260901_120000', + zipBytes: Uint8List.fromList([1]), + sha256Hash: validHash, + appVersion: '1.0.2', + ); + expect(result.success, isFalse); + expect(result.isRetryable, isTrue); + }); + }); +}