From 5fcaafbe3695b2b3328f341809a26a59160ddc4e Mon Sep 17 00:00:00 2001 From: Developer Date: Tue, 13 Oct 2026 13:50:37 -0400 Subject: [PATCH] fix(alerts): restore auth on startup, enforce token header, and prevent immutable list sort crashes --- .../alerts/ga511_road_alert_provider.dart | 12 + .../multi_source_alert_deduplicator.dart | 2 +- .../alerts/road_alert_compliance.dart | 4 +- lib/services/alerts/road_alert_service.dart | 12 +- .../alerts/zyla_road_alert_provider.dart | 9 + lib/services/auth/auth_service.dart | 67 +++- test/multi_source_road_alerts_test.dart | 19 + ..._and_immutable_alerts_regression_test.dart | 331 ++++++++++++++++++ 8 files changed, 443 insertions(+), 13 deletions(-) create mode 100644 test/zyla_auth_and_immutable_alerts_regression_test.dart diff --git a/lib/services/alerts/ga511_road_alert_provider.dart b/lib/services/alerts/ga511_road_alert_provider.dart index 3fa02b0..a28e7fd 100644 --- a/lib/services/alerts/ga511_road_alert_provider.dart +++ b/lib/services/alerts/ga511_road_alert_provider.dart @@ -67,15 +67,27 @@ class Georgia511RoadAlertProvider implements RoadAlertProvider { Future testConnection(String apiKey) async { try { final headers = await _buildHeaders(apiKey: apiKey); + if (getAuthToken != null && !headers.containsKey('Authorization')) { + if (kDebugMode) { + debugPrint('[Georgia511RoadAlertProvider] testConnection: Missing Authorization token (auth token returned null/empty and no API key provided)'); + } + return RoadAlertProviderStatus.authError; + } final uri = Uri.parse('$backendBaseUrl/api/v1/alerts/ops/health'); final res = await _client.get(uri, headers: headers).timeout(const Duration(seconds: 4)); if (res.statusCode == 200) { return RoadAlertProviderStatus.connected; } else if (res.statusCode == 401 || res.statusCode == 403) { + if (kDebugMode) { + debugPrint('[Georgia511RoadAlertProvider] testConnection auth rejected: ${res.statusCode} ${res.body}'); + } return RoadAlertProviderStatus.authError; } return RoadAlertProviderStatus.networkError; } catch (e) { + if (kDebugMode) { + debugPrint('[Georgia511RoadAlertProvider] testConnection error: $e'); + } return RoadAlertProviderStatus.networkError; } } diff --git a/lib/services/alerts/multi_source_alert_deduplicator.dart b/lib/services/alerts/multi_source_alert_deduplicator.dart index c9aab90..d1e0ea7 100644 --- a/lib/services/alerts/multi_source_alert_deduplicator.dart +++ b/lib/services/alerts/multi_source_alert_deduplicator.dart @@ -69,7 +69,7 @@ class MultiSourceAlertDeduplicator { Set dismissedIds = const {}, double proximityMeters = defaultProximityMeters, }) { - if (alerts.isEmpty) return const []; + if (alerts.isEmpty) return []; final activeAlerts = alerts.where((a) { if (dismissedIds.contains(a.id)) return false; diff --git a/lib/services/alerts/road_alert_compliance.dart b/lib/services/alerts/road_alert_compliance.dart index f300fd6..94a7055 100644 --- a/lib/services/alerts/road_alert_compliance.dart +++ b/lib/services/alerts/road_alert_compliance.dart @@ -46,7 +46,7 @@ class RoadAlertCompliance { required double userLng, bool complianceEnabled = true, }) { - if (!complianceEnabled) return alerts; + if (!complianceEnabled) return List.from(alerts); final jurisdiction = evaluateJurisdiction(userLat, userLng); switch (jurisdiction) { @@ -78,7 +78,7 @@ class RoadAlertCompliance { case AlertComplianceJurisdiction.standard: // United States standard: 100% full fidelity exact camera & mobile police alerts - return alerts; + return List.from(alerts); } } } diff --git a/lib/services/alerts/road_alert_service.dart b/lib/services/alerts/road_alert_service.dart index f7d0b76..7197b4f 100644 --- a/lib/services/alerts/road_alert_service.dart +++ b/lib/services/alerts/road_alert_service.dart @@ -237,11 +237,13 @@ class RoadAlertService { ); // Apply jurisdictional regulatory compliance (US standard, France danger zone, Germany/Switzerland prohibited) - final compliantList = RoadAlertCompliance.applyCompliance( - deduplicatedAlerts, - userLat: position.latitude, - userLng: position.longitude, - complianceEnabled: complianceEnabled, + final compliantList = List.from( + RoadAlertCompliance.applyCompliance( + deduplicatedAlerts, + userLat: position.latitude, + userLng: position.longitude, + complianceEnabled: complianceEnabled, + ), ); final navSession = _ref.read(navigationSessionProvider); diff --git a/lib/services/alerts/zyla_road_alert_provider.dart b/lib/services/alerts/zyla_road_alert_provider.dart index a7a715a..a2f1893 100644 --- a/lib/services/alerts/zyla_road_alert_provider.dart +++ b/lib/services/alerts/zyla_road_alert_provider.dart @@ -76,6 +76,12 @@ class ZylaRoadAlertProvider implements RoadAlertProvider { Future testConnection(String apiKey) async { try { final headers = await _buildHeaders(apiKey: apiKey); + if (getAuthToken != null && !headers.containsKey('Authorization')) { + if (kDebugMode) { + debugPrint('[ZylaRoadAlertProvider] testConnection: Missing Authorization token (auth token returned null/empty and no API key provided)'); + } + return RoadAlertProviderStatus.authError; + } final uri = Uri.parse('$backendBaseUrl/api/v1/alerts/ops/health'); final res = await _client.get(uri, headers: headers).timeout(const Duration(seconds: 4)); if (res.statusCode == 200) { @@ -93,6 +99,9 @@ class ZylaRoadAlertProvider implements RoadAlertProvider { return RoadAlertProviderStatus.connected; } if (res.statusCode == 401 || res.statusCode == 403) { + if (kDebugMode) { + debugPrint('[ZylaRoadAlertProvider] testConnection auth rejected: ${res.statusCode} ${res.body}'); + } return RoadAlertProviderStatus.authError; } return RoadAlertProviderStatus.networkError; diff --git a/lib/services/auth/auth_service.dart b/lib/services/auth/auth_service.dart index 034fd4e..969bdb6 100644 --- a/lib/services/auth/auth_service.dart +++ b/lib/services/auth/auth_service.dart @@ -88,6 +88,14 @@ class FirebaseAuthService implements AuthService { return; } + final initialUser = auth.currentUser; + if (initialUser != null) { + final appUser = _mapFirebaseUser(initialUser); + _currentState = initialUser.isAnonymous + ? AuthState.anonymous(appUser, isFirebaseConfigured: true) + : AuthState.authenticated(appUser, isFirebaseConfigured: true); + } + try { // Listen to native Firebase auth state stream _fbAuthSubscription = auth.authStateChanges().listen( @@ -474,12 +482,61 @@ class FirebaseAuthService implements AuthService { @override Future getIdToken([bool forceRefresh = false]) async { final auth = _firebaseAuth; - if (auth == null) return null; - final user = auth.currentUser; - if (user == null) return null; + if (auth == null) { + if (kDebugMode) { + debugPrint('[FirebaseAuthService] getIdToken: Firebase not configured'); + } + return null; + } + + var user = auth.currentUser; + if (user == null && _googleSignIn != null) { + try { + final googleUser = await _googleSignIn.signInSilently(); + if (googleUser != null) { + final googleAuth = await googleUser.authentication; + final credential = fb.GoogleAuthProvider.credential( + accessToken: googleAuth.accessToken, + idToken: googleAuth.idToken, + ); + final result = await auth.signInWithCredential(credential); + user = result.user; + if (user != null) { + _updateAuthenticatedUser(user); + } + } + } catch (e) { + if (kDebugMode) { + debugPrint('[FirebaseAuthService] silent Google restoration failed: $e'); + } + } + } + + // Guest / unauthenticated state: do NOT create an anonymous account silently. + if (user == null) { + if (kDebugMode) { + debugPrint('[FirebaseAuthService] getIdToken: No user available'); + } + return null; + } + try { - return await user.getIdToken(forceRefresh); - } catch (_) { + final token = await user.getIdToken(forceRefresh); + if (token == null || token.isEmpty) { + if (kDebugMode) { + debugPrint('[FirebaseAuthService] getIdToken: token returned empty'); + } + } + return token; + } catch (e) { + if (kDebugMode) { + debugPrint('[FirebaseAuthService] getIdToken error (forceRefresh=$forceRefresh): $e'); + } + if (forceRefresh) { + try { + return await user.getIdToken(false); + } catch (_) {} + } return null; } } diff --git a/test/multi_source_road_alerts_test.dart b/test/multi_source_road_alerts_test.dart index 64eff24..047776c 100644 --- a/test/multi_source_road_alerts_test.dart +++ b/test/multi_source_road_alerts_test.dart @@ -387,6 +387,25 @@ void main() { expect(status, equals(RoadAlertProviderStatus.authError)); }); + test('testConnection returns authError when getAuthToken returns null and no apiKey provided', () async { + final mockClient = MockClient((request) async { + return http.Response( + json.encode({'providers': []}), + 200, + headers: {'content-type': 'application/json'}, + ); + }); + + final provider = ZylaRoadAlertProvider( + client: mockClient, + backendBaseUrl: 'http://127.0.0.1:8000', + getAuthToken: () async => null, + ); + + final status = await provider.testConnection(''); + expect(status, equals(RoadAlertProviderStatus.authError)); + }); + test('getAlerts handles 401 Unauthorized gracefully by returning empty list', () async { final mockClient = MockClient((request) async { return http.Response( diff --git a/test/zyla_auth_and_immutable_alerts_regression_test.dart b/test/zyla_auth_and_immutable_alerts_regression_test.dart new file mode 100644 index 0000000..610cab1 --- /dev/null +++ b/test/zyla_auth_and_immutable_alerts_regression_test.dart @@ -0,0 +1,331 @@ +import 'dart:convert'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:motorcycle_dash/services/alerts/ga511_road_alert_provider.dart'; +import 'package:motorcycle_dash/services/alerts/multi_source_alert_deduplicator.dart'; +import 'package:motorcycle_dash/services/alerts/road_alert_compliance.dart'; +import 'package:motorcycle_dash/services/alerts/road_alert_models.dart'; +import 'package:motorcycle_dash/services/alerts/zyla_road_alert_provider.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + final now = DateTime.utc(2026, 10, 13, 12, 0, 0); + + final sampleAlert1 = RoadAlert( + id: 'alert_1', + providerId: 'zyla_waze_4814', + category: RoadAlertCategory.police, + severity: RoadAlertSeverity.high, + latitude: 34.2560, + longitude: -85.1650, + reportedAt: now, + lastUpdated: now, + source: 'Zyla Waze', + description: 'Police radar stationary', + ); + + final sampleAlert2 = RoadAlert( + id: 'alert_2', + providerId: 'ga511_georgia_dot', + category: RoadAlertCategory.speedCamera, + severity: RoadAlertSeverity.medium, + latitude: 34.2580, + longitude: -85.1670, + reportedAt: now.add(const Duration(minutes: 1)), + lastUpdated: now.add(const Duration(minutes: 1)), + source: 'Georgia 511', + description: 'Speed camera northbound', + ); + + final sampleAlert3 = RoadAlert( + id: 'alert_3', + providerId: 'ga511_georgia_dot', + category: RoadAlertCategory.hazard, + severity: RoadAlertSeverity.low, + latitude: 34.2590, + longitude: -85.1680, + reportedAt: now.add(const Duration(minutes: 2)), + lastUpdated: now.add(const Duration(minutes: 2)), + source: 'Georgia 511', + description: 'Debris on road', + ); + + group('Zyla & GA511 Road Alert Provider Authentication Scenarios', () { + test('1. Authenticated scenario: passes Bearer token to backend and returns connected on 200', () async { + String? capturedAuthHeader; + final mockClient = MockClient((request) async { + capturedAuthHeader = request.headers['Authorization']; + return http.Response( + json.encode({ + 'providers': [ + {'provider_id': 'zyla_waze_4814', 'status': 'operational'}, + ], + }), + 200, + headers: {'content-type': 'application/json'}, + ); + }); + + final provider = ZylaRoadAlertProvider( + client: mockClient, + backendBaseUrl: 'http://127.0.0.1:8000', + getAuthToken: () async => 'valid_firebase_id_token_xyz', + ); + + final status = await provider.testConnection(''); + expect(status, equals(RoadAlertProviderStatus.connected)); + expect(capturedAuthHeader, equals('Bearer valid_firebase_id_token_xyz')); + }); + + test('2. Missing-token scenario: getAuthToken returns null fails closed as authError', () async { + bool networkCallMade = false; + final mockClient = MockClient((request) async { + networkCallMade = true; + return http.Response(json.encode({'providers': []}), 200); + }); + + final provider = ZylaRoadAlertProvider( + client: mockClient, + backendBaseUrl: 'http://127.0.0.1:8000', + getAuthToken: () async => null, + ); + + final status = await provider.testConnection(''); + expect(status, equals(RoadAlertProviderStatus.authError)); + expect(networkCallMade, isFalse); + }); + + test('3. Missing-token scenario (empty string): fails closed as authError', () async { + bool networkCallMade = false; + final mockClient = MockClient((request) async { + networkCallMade = true; + return http.Response(json.encode({'providers': []}), 200); + }); + + final provider = Georgia511RoadAlertProvider( + client: mockClient, + backendBaseUrl: 'http://127.0.0.1:8000', + getAuthToken: () async => '', + ); + + final status = await provider.testConnection(''); + expect(status, equals(RoadAlertProviderStatus.authError)); + expect(networkCallMade, isFalse); + }); + + test('4. Expired-token / 401 Unauthorized scenario: backend rejects token as authError', () async { + final mockClient = MockClient((request) async { + return http.Response( + json.encode({'detail': 'Token expired or invalid'}), + 401, + headers: {'content-type': 'application/json'}, + ); + }); + + final provider = ZylaRoadAlertProvider( + client: mockClient, + backendBaseUrl: 'http://127.0.0.1:8000', + getAuthToken: () async => 'expired_firebase_token', + ); + + final status = await provider.testConnection(''); + expect(status, equals(RoadAlertProviderStatus.authError)); + }); + + test('5. Expired-token / 403 Forbidden scenario: backend rejects as authError', () async { + final mockClient = MockClient((request) async { + return http.Response( + json.encode({'detail': 'Forbidden'}), + 403, + headers: {'content-type': 'application/json'}, + ); + }); + + final provider = Georgia511RoadAlertProvider( + client: mockClient, + backendBaseUrl: 'http://127.0.0.1:8000', + getAuthToken: () async => 'unauthorized_token', + ); + + final status = await provider.testConnection(''); + expect(status, equals(RoadAlertProviderStatus.authError)); + }); + + test('6. Guest scenario: guest caller with null token returns authError and does not call backend', () async { + int anonymousBootstrapAttempts = 0; + Future guestTokenGetter() async { + // Guest mode: no signed-in user, does not create an anonymous user + return null; + } + + final mockClient = MockClient((request) async { + return http.Response(json.encode({'status': 'ok'}), 200); + }); + + final provider = ZylaRoadAlertProvider( + client: mockClient, + backendBaseUrl: 'http://127.0.0.1:8000', + getAuthToken: guestTokenGetter, + ); + + final status = await provider.testConnection(''); + expect(status, equals(RoadAlertProviderStatus.authError)); + expect(anonymousBootstrapAttempts, equals(0)); + }); + + test('7. API key override fallback: when apiKey is provided, it is attached as Bearer header', () async { + String? capturedAuthHeader; + final mockClient = MockClient((request) async { + capturedAuthHeader = request.headers['Authorization']; + return http.Response( + json.encode({'providers': [{'provider_id': 'zyla_waze_4814', 'status': 'operational'}]}), + 200, + ); + }); + + final provider = ZylaRoadAlertProvider( + client: mockClient, + backendBaseUrl: 'http://127.0.0.1:8000', + getAuthToken: null, + ); + + final status = await provider.testConnection('custom_secret_api_key'); + expect(status, equals(RoadAlertProviderStatus.connected)); + expect(capturedAuthHeader, equals('Bearer custom_secret_api_key')); + }); + }); + + group('Empty and Immutable Alert Lists Regression Tests', () { + test('1. MultiSourceAlertDeduplicator.deduplicate handles const [] without throwing and returns mutable list', () { + const constEmptyList = []; + final result = MultiSourceAlertDeduplicator.deduplicate(constEmptyList); + + expect(result, isEmpty); + // Prove returned list is mutable and can be sorted + expect(() => result.sort((a, b) => a.id.compareTo(b.id)), returnsNormally); + expect(() => result.add(sampleAlert1), returnsNormally); + expect(result.length, equals(1)); + }); + + test('2. MultiSourceAlertDeduplicator.deduplicate handles List.unmodifiable without crashing on sorting', () { + final unmodifiableList = List.unmodifiable([sampleAlert2, sampleAlert1]); + + expect(() => unmodifiableList.sort(), throwsUnsupportedError); + + final result = MultiSourceAlertDeduplicator.deduplicate(unmodifiableList); + expect(result.length, equals(2)); + // Prove result is a new mutable list that can be sorted + expect(() => result.sort((a, b) => a.reportedAt.compareTo(b.reportedAt)), returnsNormally); + }); + + test('3. RoadAlertCompliance.applyCompliance handles const [] across all jurisdictions without returning unmodifiable list', () { + const constEmptyList = []; + + // Standard US + final usResult = RoadAlertCompliance.applyCompliance( + constEmptyList, + userLat: 34.2560, + userLng: -85.1650, + ); + expect(usResult, isEmpty); + expect(() => usResult.sort((a, b) => a.id.compareTo(b.id)), returnsNormally); + + // Compliance disabled + final disabledResult = RoadAlertCompliance.applyCompliance( + constEmptyList, + userLat: 34.2560, + userLng: -85.1650, + complianceEnabled: false, + ); + expect(disabledResult, isEmpty); + expect(() => disabledResult.sort((a, b) => a.id.compareTo(b.id)), returnsNormally); + + // French danger zone + final frResult = RoadAlertCompliance.applyCompliance( + constEmptyList, + userLat: 48.8566, + userLng: 2.3522, + ); + expect(frResult, isEmpty); + expect(() => frResult.sort((a, b) => a.id.compareTo(b.id)), returnsNormally); + + // German prohibited zone + final deResult = RoadAlertCompliance.applyCompliance( + constEmptyList, + userLat: 52.5200, + userLng: 13.4050, + ); + expect(deResult, isEmpty); + expect(() => deResult.sort((a, b) => a.id.compareTo(b.id)), returnsNormally); + }); + + test('4. RoadAlertCompliance.applyCompliance handles List.unmodifiable and returns mutable list', () { + final unmodifiableList = List.unmodifiable([sampleAlert1, sampleAlert2, sampleAlert3]); + + // Standard US + final usResult = RoadAlertCompliance.applyCompliance( + unmodifiableList, + userLat: 34.2560, + userLng: -85.1650, + ); + expect(usResult.length, equals(3)); + expect(() => usResult.sort((a, b) => a.id.compareTo(b.id)), returnsNormally); + + // Compliance disabled + final disabledResult = RoadAlertCompliance.applyCompliance( + unmodifiableList, + userLat: 34.2560, + userLng: -85.1650, + complianceEnabled: false, + ); + expect(disabledResult.length, equals(3)); + expect(() => disabledResult.sort((a, b) => a.id.compareTo(b.id)), returnsNormally); + + // French danger zone transforms speedCamera to hazard + final frResult = RoadAlertCompliance.applyCompliance( + unmodifiableList, + userLat: 48.8566, + userLng: 2.3522, + ); + expect(frResult.length, equals(3)); + expect(frResult.where((a) => a.category == RoadAlertCategory.hazard).length, equals(2)); + expect(() => frResult.sort((a, b) => a.id.compareTo(b.id)), returnsNormally); + + // German prohibited suppresses speed camera and police radar trap, retains general hazard + final deResult = RoadAlertCompliance.applyCompliance( + unmodifiableList, + userLat: 52.5200, + userLng: 13.4050, + ); + expect(deResult.length, equals(1)); + expect(deResult.first.category, equals(RoadAlertCategory.hazard)); + expect(() => deResult.sort((a, b) => a.id.compareTo(b.id)), returnsNormally); + }); + + test('5. End-to-end pipeline: immutable list -> deduplicate -> compliance -> in-place sorting succeeds', () { + final immutableAlerts = List.unmodifiable([sampleAlert2, sampleAlert1]); + + final deduplicated = MultiSourceAlertDeduplicator.deduplicate(immutableAlerts); + final compliant = List.from( + RoadAlertCompliance.applyCompliance( + deduplicated, + userLat: 34.2560, + userLng: -85.1650, + ), + ); + + // Simulates RoadAlertService line 252 sort + expect(() { + compliant.sort((a, b) { + final comp = a.severity.index.compareTo(b.severity.index); + if (comp != 0) return comp; + return a.reportedAt.compareTo(b.reportedAt); + }); + }, returnsNormally); + + expect(compliant.length, equals(2)); + }); + }); +}