fix(alerts): restore auth on startup, enforce token header, and prevent immutable list sort crashes

This commit is contained in:
Developer 2026-10-13 13:50:37 -04:00
parent 35f2b6982a
commit 5fcaafbe36
8 changed files with 443 additions and 13 deletions

View File

@ -67,15 +67,27 @@ class Georgia511RoadAlertProvider implements RoadAlertProvider {
Future<RoadAlertProviderStatus> 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;
}
}

View File

@ -69,7 +69,7 @@ class MultiSourceAlertDeduplicator {
Set<String> dismissedIds = const {},
double proximityMeters = defaultProximityMeters,
}) {
if (alerts.isEmpty) return const [];
if (alerts.isEmpty) return <RoadAlert>[];
final activeAlerts = alerts.where((a) {
if (dismissedIds.contains(a.id)) return false;

View File

@ -46,7 +46,7 @@ class RoadAlertCompliance {
required double userLng,
bool complianceEnabled = true,
}) {
if (!complianceEnabled) return alerts;
if (!complianceEnabled) return List<RoadAlert>.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<RoadAlert>.from(alerts);
}
}
}

View File

@ -237,11 +237,13 @@ class RoadAlertService {
);
// Apply jurisdictional regulatory compliance (US standard, France danger zone, Germany/Switzerland prohibited)
final compliantList = RoadAlertCompliance.applyCompliance(
final compliantList = List<RoadAlert>.from(
RoadAlertCompliance.applyCompliance(
deduplicatedAlerts,
userLat: position.latitude,
userLng: position.longitude,
complianceEnabled: complianceEnabled,
),
);
final navSession = _ref.read(navigationSessionProvider);

View File

@ -76,6 +76,12 @@ class ZylaRoadAlertProvider implements RoadAlertProvider {
Future<RoadAlertProviderStatus> 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;

View File

@ -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<String?> 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 {
return await user.getIdToken(forceRefresh);
} catch (_) {
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 {
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;
}
}

View File

@ -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(

View File

@ -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<String?> 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 <RoadAlert>[] without throwing and returns mutable list', () {
const constEmptyList = <RoadAlert>[];
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<RoadAlert>.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 = <RoadAlert>[];
// 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<RoadAlert>.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<RoadAlert>.unmodifiable([sampleAlert2, sampleAlert1]);
final deduplicated = MultiSourceAlertDeduplicator.deduplicate(immutableAlerts);
final compliant = List<RoadAlert>.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));
});
});
}