From 3ca69f9be44593c7602c89c90f5a11dae11f04b2 Mon Sep 17 00:00:00 2001 From: Developer Date: Sun, 6 Sep 2026 12:24:45 -0400 Subject: [PATCH] fix: activate and validate real motorcycle routing --- android/app/src/debug/AndroidManifest.xml | 1 + .../debug/res/xml/network_security_config.xml | 8 + .../widgets/address_autocomplete_field.dart | 17 +- lib/services/auth/auth_service.dart | 33 ++++ .../map/open_street_map_provider.dart | 99 ++++++++--- lib/services/routes/route_providers.dart | 12 +- .../routes/valhalla_route_provider.dart | 23 ++- server/waze_relay/middleware/firebase_auth.js | 11 +- server/waze_relay/routes/places.js | 6 +- test/account_cloud_cleanup_service_test.dart | 2 + ...ccount_deletion_lifecycle_widget_test.dart | 2 + test/account_lifecycle_test.dart | 2 + test/auth_bootstrap_test.dart | 156 ++++++++++++++++++ test/auth_test.dart | 1 + ...al_group_rides_social_responsive_test.dart | 2 + ..._settings_account_pro_responsive_test.dart | 2 + ...ical_whole_app_regression_matrix_test.dart | 2 + test/cloud_restore_manual_test.dart | 1 + test/cloud_sync_hardening_test.dart | 1 + test/firebase_sync_test.dart | 1 + test/ownership_isolation_test.dart | 1 + test/r2_security_audit_test.dart | 1 + test/social_screens_widget_test.dart | 2 + test/valhalla_route_provider_test.dart | 124 +++++++++++++- 24 files changed, 474 insertions(+), 36 deletions(-) create mode 100644 android/app/src/debug/res/xml/network_security_config.xml create mode 100644 test/auth_bootstrap_test.dart diff --git a/android/app/src/debug/AndroidManifest.xml b/android/app/src/debug/AndroidManifest.xml index 399f698..83d4e12 100644 --- a/android/app/src/debug/AndroidManifest.xml +++ b/android/app/src/debug/AndroidManifest.xml @@ -4,4 +4,5 @@ to allow setting breakpoints, to provide hot reload, etc. --> + diff --git a/android/app/src/debug/res/xml/network_security_config.xml b/android/app/src/debug/res/xml/network_security_config.xml new file mode 100644 index 0000000..3f1b761 --- /dev/null +++ b/android/app/src/debug/res/xml/network_security_config.xml @@ -0,0 +1,8 @@ + + + + 10.0.2.2 + 127.0.0.1 + localhost + + diff --git a/lib/features/routes/widgets/address_autocomplete_field.dart b/lib/features/routes/widgets/address_autocomplete_field.dart index 1d3cb25..c88f0f1 100644 --- a/lib/features/routes/widgets/address_autocomplete_field.dart +++ b/lib/features/routes/widgets/address_autocomplete_field.dart @@ -1,4 +1,4 @@ -import 'dart:async'; +import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../core/theme/theme_extensions.dart'; @@ -72,6 +72,8 @@ class _AddressAutocompleteFieldState bool _showDropdown = false; bool _hasSearched = false; + String _lastText = ''; + @override void initState() { super.initState(); @@ -92,8 +94,17 @@ class _AddressAutocompleteFieldState } _resolvedPlace = widget.initialPlace; + _lastText = _controller.text; _focusNode.addListener(_handleFocusChange); + _controller.addListener(_handleControllerChange); + } + + void _handleControllerChange() { + if (_controller.text != _lastText) { + _lastText = _controller.text; + _onTextChanged(_controller.text); + } } @override @@ -105,6 +116,7 @@ class _AddressAutocompleteFieldState if (_controller.text != newText) { WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted && _controller.text != newText) { + _lastText = newText; _controller.text = newText; } }); @@ -115,6 +127,7 @@ class _AddressAutocompleteFieldState @override void dispose() { _debounceTimer?.cancel(); + _controller.removeListener(_handleControllerChange); _focusNode.removeListener(_handleFocusChange); if (_internalFocusNode) { _focusNode.dispose(); @@ -232,6 +245,7 @@ class _AddressAutocompleteFieldState void _applyResolvedPlace(ResolvedPlace place) { _resolvedPlace = place; + _lastText = place.displayName; _controller.text = place.displayName; setState(() { _isResolving = false; @@ -244,6 +258,7 @@ class _AddressAutocompleteFieldState } void _clear() { + _lastText = ''; _controller.clear(); _resolvedPlace = null; _suggestions = const []; diff --git a/lib/services/auth/auth_service.dart b/lib/services/auth/auth_service.dart index dd2f5d9..4f9ffa7 100644 --- a/lib/services/auth/auth_service.dart +++ b/lib/services/auth/auth_service.dart @@ -20,6 +20,7 @@ abstract class AuthService { Future createUserWithEmailAndPassword(String email, String password, {String? displayName}); Future signInWithGoogle(); Future signInAnonymously(); + Future ensureBackendIdentity(); Future sendPasswordResetEmail(String email); Future sendEmailVerification() async => throw UnsupportedError('Email verification is not supported by this auth service.'); Future reloadCurrentUser() async => throw UnsupportedError('User reload is not supported by this auth service.'); @@ -351,6 +352,38 @@ class FirebaseAuthService implements AuthService { } } + Future? _identityBootstrapFuture; + + @override + Future ensureBackendIdentity() { + final auth = _firebaseAuth; + if (auth != null && auth.currentUser != null) { + return Future.value(); + } + + _identityBootstrapFuture ??= _performIdentityBootstrap().whenComplete(() { + _identityBootstrapFuture = null; + }); + return _identityBootstrapFuture!; + } + + Future _performIdentityBootstrap() async { + final auth = _firebaseAuth; + if (auth == null || !isConfigured) { + throw StateError('Firebase is not configured. Backend identity cannot be established.'); + } + if (auth.currentUser != null) return; + try { + final credential = await auth.signInAnonymously(); + if (credential.user != null) { + final user = _mapFirebaseUser(credential.user!); + _updateState(AuthState.anonymous(user, isFirebaseConfigured: true)); + } + } catch (e) { + throw Exception('Backend identity bootstrap failed: $e'); + } + } + @override Future sendPasswordResetEmail(String email) async { final auth = _firebaseAuth; diff --git a/lib/services/map/open_street_map_provider.dart b/lib/services/map/open_street_map_provider.dart index 44583e2..7129b04 100644 --- a/lib/services/map/open_street_map_provider.dart +++ b/lib/services/map/open_street_map_provider.dart @@ -5,10 +5,6 @@ import 'package:latlong2/latlong.dart' as ll; import 'map_provider_interface.dart'; class OpenStreetMapProvider implements AppMapProvider { - final MapController _mapController = MapController(); - ll.LatLng? _lastFollowPos; - DateTime? _lastFollowTime; - @override AppMapType get type => AppMapType.openStreetMap; @@ -27,9 +23,60 @@ class OpenStreetMapProvider implements AppMapProvider { required ValueChanged? onPositionChanged, required VoidCallback? onUserInteraction, }) { - final centerLl = ll.LatLng(center.latitude, center.longitude); + return _OsmMapView( + center: center, + zoom: zoom, + markers: markers, + polylines: polylines, + isFollowMode: isFollowMode, + config: config, + onPositionChanged: onPositionChanged, + onUserInteraction: onUserInteraction, + ); + } +} - if (isFollowMode) { +class _OsmMapView extends StatefulWidget { + final AppLatLng center; + final double zoom; + final List markers; + final List polylines; + final bool isFollowMode; + final MapConfig config; + final ValueChanged? onPositionChanged; + final VoidCallback? onUserInteraction; + + const _OsmMapView({ + required this.center, + required this.zoom, + required this.markers, + required this.polylines, + required this.isFollowMode, + required this.config, + required this.onPositionChanged, + required this.onUserInteraction, + }); + + @override + State<_OsmMapView> createState() => _OsmMapViewState(); +} + +class _OsmMapViewState extends State<_OsmMapView> { + late final MapController _mapController; + ll.LatLng? _lastFollowPos; + DateTime? _lastFollowTime; + + @override + void initState() { + super.initState(); + _mapController = MapController(); + } + + @override + void didUpdateWidget(covariant _OsmMapView oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.isFollowMode) { + final centerLl = ll.LatLng(widget.center.latitude, widget.center.longitude); final now = DateTime.now(); final posDelta = _lastFollowPos == null ? 1.0 @@ -43,13 +90,25 @@ class OpenStreetMapProvider implements AppMapProvider { _lastFollowPos = centerLl; _lastFollowTime = now; WidgetsBinding.instance.addPostFrameCallback((_) { - try { - _mapController.move(centerLl, zoom); - } catch (_) {} + if (mounted) { + try { + _mapController.move(centerLl, widget.zoom); + } catch (_) {} + } }); } } + } + @override + void dispose() { + _mapController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final centerLl = ll.LatLng(widget.center.latitude, widget.center.longitude); final media = MediaQuery.of(context); final sizeKey = '${media.orientation}_${media.size.width.toInt()}x${media.size.height.toInt()}'; @@ -58,30 +117,30 @@ class OpenStreetMapProvider implements AppMapProvider { mapController: _mapController, options: MapOptions( initialCenter: centerLl, - initialZoom: zoom, - minZoom: config.minZoom, - maxZoom: config.maxZoom, + initialZoom: widget.zoom, + minZoom: widget.config.minZoom, + maxZoom: widget.config.maxZoom, backgroundColor: const Color(0xFF0F0F0F), onPositionChanged: (position, hasGesture) { if (hasGesture) { - onUserInteraction?.call(); + widget.onUserInteraction?.call(); } - if (onPositionChanged != null) { + if (widget.onPositionChanged != null) { final c = position.center; - onPositionChanged(AppLatLng(c.latitude, c.longitude)); + widget.onPositionChanged!(AppLatLng(c.latitude, c.longitude)); } }, ), children: [ TileLayer( - urlTemplate: config.tileUrlTemplate, - subdomains: config.subdomains, + urlTemplate: widget.config.tileUrlTemplate, + subdomains: widget.config.subdomains, userAgentPackageName: 'app.motorsignal.motorcycle', maxZoom: 20, maxNativeZoom: 19, ), PolylineLayer( - polylines: polylines.map((p) { + polylines: widget.polylines.map((p) { return Polyline( points: p.points.map((pt) => ll.LatLng(pt.latitude, pt.longitude)).toList(), strokeWidth: p.width, @@ -90,7 +149,7 @@ class OpenStreetMapProvider implements AppMapProvider { }).toList(), ), MarkerLayer( - markers: markers.map((m) { + markers: widget.markers.map((m) { return Marker( point: ll.LatLng(m.position.latitude, m.position.longitude), width: 50, @@ -113,7 +172,7 @@ class OpenStreetMapProvider implements AppMapProvider { RichAttributionWidget( attributions: [ TextSourceAttribution( - config.attributionText, + widget.config.attributionText, textStyle: const TextStyle(color: Colors.white70, fontSize: 10), ), ], diff --git a/lib/services/routes/route_providers.dart b/lib/services/routes/route_providers.dart index ef7db2c..12a4325 100644 --- a/lib/services/routes/route_providers.dart +++ b/lib/services/routes/route_providers.dart @@ -5,15 +5,21 @@ import 'navigation_handoff.dart'; import 'route_models.dart'; import 'route_provider.dart'; import 'route_service.dart'; - +import 'valhalla_route_provider.dart'; /// Provider for RouteService. final routeServiceProvider = Provider((ref) { return RouteService(); }); -/// Active RouteProvider implementation (defaults to honest straight-line preview). +/// Active RouteProvider implementation. final activeRouteCalculationProvider = Provider((ref) { - return const StraightLineRouteProvider(); + final authService = ref.watch(authServiceProvider); + return ValhallaRouteProvider( + tokenProvider: () async { + await authService.ensureBackendIdentity(); + return await authService.getIdToken(true); + }, + ); }); /// Provider for NavigationHandoffService. diff --git a/lib/services/routes/valhalla_route_provider.dart b/lib/services/routes/valhalla_route_provider.dart index eb1f679..244a2ba 100644 --- a/lib/services/routes/valhalla_route_provider.dart +++ b/lib/services/routes/valhalla_route_provider.dart @@ -1,3 +1,4 @@ +import 'package:flutter/foundation.dart'; import 'dart:async'; import 'dart:convert'; import 'package:http/http.dart' as http; @@ -11,20 +12,32 @@ import 'valhalla_polyline_decoder.dart'; class ValhallaRouteProvider implements RouteProvider { final String backendUrl; final http.Client _client; - final Future Function()? _tokenProvider; + final Future Function()? tokenProvider; ValhallaRouteProvider({ - this.backendUrl = 'http://10.0.2.2:3000', + String? backendUrl, http.Client? client, - this._tokenProvider, - }) : _client = client ?? http.Client(); + this.tokenProvider, + }) : _client = client ?? http.Client(), + backendUrl = backendUrl ?? _getEffectiveBackendUrl(); + + static String _getEffectiveBackendUrl() { + const definedUrl = String.fromEnvironment('MOTORSIGNAL_API_BASE_URL'); + if (definedUrl.isNotEmpty) { + return definedUrl; + } + if (kReleaseMode) { + throw StateError('MOTORSIGNAL_API_BASE_URL is not configured for production build.'); + } + return 'http://10.0.2.2:3000'; + } @override String get name => 'Stadia Maps (Valhalla)'; Future> _getAuthHeaders() async { String? token; - final customTokenProvider = _tokenProvider; + final customTokenProvider = tokenProvider; if (customTokenProvider != null) { token = await customTokenProvider(); } else { diff --git a/server/waze_relay/middleware/firebase_auth.js b/server/waze_relay/middleware/firebase_auth.js index f969109..a7d5911 100644 --- a/server/waze_relay/middleware/firebase_auth.js +++ b/server/waze_relay/middleware/firebase_auth.js @@ -1,9 +1,13 @@ const admin = require('firebase-admin'); +const { getAuth } = require('firebase-admin/auth'); // Initialize Firebase Admin if not already initialized try { - if (admin.apps && admin.apps.length === 0) { - admin.initializeApp(); + const apps = admin.getApps ? admin.getApps() : (admin.apps || []); + if (apps.length === 0) { + admin.initializeApp({ + projectId: process.env.FIREBASE_PROJECT_ID || process.env.GCLOUD_PROJECT || 'motoapp-d22e0' + }); } } catch (e) { console.warn('Firebase admin initialization failed:', e.message); @@ -24,7 +28,8 @@ async function requireAuth(req, res, next) { const idToken = authHeader.split('Bearer ')[1]; try { - const decodedToken = await admin.auth().verifyIdToken(idToken); + const auth = typeof admin.auth === 'function' ? admin.auth() : getAuth(); + const decodedToken = await auth.verifyIdToken(idToken); req.user = decodedToken; next(); } catch (error) { diff --git a/server/waze_relay/routes/places.js b/server/waze_relay/routes/places.js index a48adb1..588652d 100644 --- a/server/waze_relay/routes/places.js +++ b/server/waze_relay/routes/places.js @@ -1,4 +1,4 @@ -const express = require('express'); +const express = require('express'); const axios = require('axios'); const rateLimit = require('express-rate-limit'); const { requireAuth } = require('../middleware/firebase_auth'); @@ -114,7 +114,7 @@ router.get('/search', requireAppCheck, requireAuth, searchLimiter, async (req, r res.json({ results: normalized, - attribution: 'Powered by Stadia Maps, © OpenStreetMap contributors' + attribution: 'Powered by Stadia Maps, \u00A9 OpenStreetMap contributors' }); } catch (error) { if (error.code === 'ECONNABORTED' || error.message?.includes('timeout')) { @@ -190,7 +190,7 @@ router.get('/resolve', requireAppCheck, requireAuth, resolveLimiter, async (req, formattedAddress: props.label || props.name || '', latitude: coords[1], longitude: coords[0], - attribution: 'Powered by Stadia Maps, © OpenStreetMap contributors' + attribution: 'Powered by Stadia Maps, \u00A9 OpenStreetMap contributors' }); } catch (error) { if (error.code === 'ECONNABORTED' || error.message?.includes('timeout')) { diff --git a/test/account_cloud_cleanup_service_test.dart b/test/account_cloud_cleanup_service_test.dart index 7a5dcca..cf0dde6 100644 --- a/test/account_cloud_cleanup_service_test.dart +++ b/test/account_cloud_cleanup_service_test.dart @@ -9,6 +9,8 @@ import 'package:motorcycle_dash/services/auth/auth_user.dart'; import 'package:motorcycle_dash/services/cloud/account_cloud_cleanup_service.dart'; class _Auth implements AuthService { + @override Future ensureBackendIdentity() async {} + final _states = StreamController.broadcast(); String? token = 'test-token'; AuthUser? user = const AuthUser( diff --git a/test/account_deletion_lifecycle_widget_test.dart b/test/account_deletion_lifecycle_widget_test.dart index c93bc0d..e04949e 100644 --- a/test/account_deletion_lifecycle_widget_test.dart +++ b/test/account_deletion_lifecycle_widget_test.dart @@ -18,6 +18,8 @@ import 'package:motorcycle_dash/features/records/records_service.dart'; import 'package:shared_preferences/shared_preferences.dart'; class _FakeAuth implements AuthService { + @override Future ensureBackendIdentity() async {} + final _states = StreamController.broadcast(); AuthUser? user = const AuthUser(uid: 'delete-test', email: 'delete@test.com', providerId: 'password', emailVerified: true); bool reauthenticated = false; diff --git a/test/account_lifecycle_test.dart b/test/account_lifecycle_test.dart index 085ceaf..178a464 100644 --- a/test/account_lifecycle_test.dart +++ b/test/account_lifecycle_test.dart @@ -8,6 +8,8 @@ import 'package:motorcycle_dash/services/auth/auth_user.dart'; import 'package:motorcycle_dash/services/cloud/account_cloud_cleanup_service.dart'; class _Auth implements AuthService { + @override Future ensureBackendIdentity() async {} + AuthUser? user = const AuthUser(uid: 'user_a', email: 'a@test.com'); bool reauthenticated = false; bool deleted = false; diff --git a/test/auth_bootstrap_test.dart b/test/auth_bootstrap_test.dart new file mode 100644 index 0000000..35a3ff0 --- /dev/null +++ b/test/auth_bootstrap_test.dart @@ -0,0 +1,156 @@ +import 'dart:async'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:firebase_auth/firebase_auth.dart' as fb; +import 'package:motorcycle_dash/services/auth/auth_service.dart'; + +class MockFirebaseAuth implements fb.FirebaseAuth { + fb.User? _currentUser; + final StreamController _authStateChangesController = StreamController.broadcast(); + int signInAnonymouslyCallCount = 0; + bool shouldThrowOnSignIn = false; + + MockFirebaseAuth(this._currentUser); + + @override + fb.User? get currentUser => _currentUser; + + @override + Stream authStateChanges() async* { + yield _currentUser; + yield* _authStateChangesController.stream; + } + + @override + Future signInAnonymously() async { + signInAnonymouslyCallCount++; + if (shouldThrowOnSignIn) { + throw fb.FirebaseAuthException(code: 'network-error', message: 'Network disconnected'); + } + await Future.delayed(const Duration(milliseconds: 50)); + final user = MockUser('new_anon', true, null); + _currentUser = user; + _authStateChangesController.add(user); + return MockUserCredential(user); + } + + @override + dynamic noSuchMethod(Invocation invocation) { + if (invocation.isGetter) return null; + return super.noSuchMethod(invocation); + } +} + +class MockUser implements fb.User { + @override + final String uid; + @override + final bool isAnonymous; + @override + final String? email; + @override + final bool emailVerified = false; + @override + final fb.UserMetadata metadata; + @override + final List providerData = const []; + + MockUser(this.uid, this.isAnonymous, this.email) : metadata = MockUserMetadata(); + + @override + dynamic noSuchMethod(Invocation invocation) { + if (invocation.isGetter) return null; + return super.noSuchMethod(invocation); + } +} + +class MockUserMetadata implements fb.UserMetadata { + @override + DateTime? get creationTime => DateTime.now(); + @override + DateTime? get lastSignInTime => DateTime.now(); + + @override + dynamic noSuchMethod(Invocation invocation) { + if (invocation.isGetter) return null; + return super.noSuchMethod(invocation); + } +} + +class MockUserCredential implements fb.UserCredential { + @override + final fb.User? user; + + MockUserCredential(this.user); + + @override + dynamic noSuchMethod(Invocation invocation) { + if (invocation.isGetter) return null; + return super.noSuchMethod(invocation); + } +} + +void main() { + group('AuthService ensureBackendIdentity Tests', () { + test('current registered user -> reused, no anonymous sign-in', () async { + final fakeUser = MockUser('reg_123', false, 'test@example.com'); + final fakeAuth = MockFirebaseAuth(fakeUser); + final service = FirebaseAuthService(firebaseAuth: fakeAuth); + await Future.delayed(Duration.zero); // allow init to run + + await service.ensureBackendIdentity(); + + expect(fakeAuth.signInAnonymouslyCallCount, 0); + expect(service.currentUser?.isAnonymous, false); + expect(service.currentUser?.uid, 'reg_123'); + }); + + test('current anonymous user -> reused', () async { + final fakeUser = MockUser('anon_123', true, null); + final fakeAuth = MockFirebaseAuth(fakeUser); + final service = FirebaseAuthService(firebaseAuth: fakeAuth); + await Future.delayed(Duration.zero); + + await service.ensureBackendIdentity(); + + expect(fakeAuth.signInAnonymouslyCallCount, 0); + expect(service.currentUser?.isAnonymous, true); + }); + + test('null user -> one anonymous sign-in', () async { + final fakeAuth = MockFirebaseAuth(null); + final service = FirebaseAuthService(firebaseAuth: fakeAuth); + await Future.delayed(Duration.zero); + + await service.ensureBackendIdentity(); + + expect(fakeAuth.signInAnonymouslyCallCount, 1); + expect(service.currentState.user?.uid, 'new_anon'); + }); + + test('two simultaneous requests -> only one anonymous sign-in', () async { + final fakeAuth = MockFirebaseAuth(null); + final service = FirebaseAuthService(firebaseAuth: fakeAuth); + await Future.delayed(Duration.zero); + + final future1 = service.ensureBackendIdentity(); + final future2 = service.ensureBackendIdentity(); + + await Future.wait([future1, future2]); + + expect(fakeAuth.signInAnonymouslyCallCount, 1); + expect(service.currentState.user?.uid, 'new_anon'); + }); + + test('anonymous sign-in failure -> explicit provider/auth error', () async { + final fakeAuth = MockFirebaseAuth(null); + fakeAuth.shouldThrowOnSignIn = true; + final service = FirebaseAuthService(firebaseAuth: fakeAuth); + await Future.delayed(Duration.zero); + + expect( + () => service.ensureBackendIdentity(), + throwsA(isA().having((e) => e.toString(), 'msg', contains('Network disconnected'))) + ); + }); + }); +} diff --git a/test/auth_test.dart b/test/auth_test.dart index 9b51a84..607a0fa 100644 --- a/test/auth_test.dart +++ b/test/auth_test.dart @@ -16,6 +16,7 @@ class FakeAuthService implements AuthService { @override Future reloadCurrentUser() async {} @override Future reauthenticate({String? password}) async {} @override Future deleteCurrentUser() async {} + @override Future ensureBackendIdentity() async {} final _stateController = StreamController.broadcast(); AuthState _currentState = const AuthState.unauthenticated(isFirebaseConfigured: true); bool _isConfigured = true; diff --git a/test/canonical_group_rides_social_responsive_test.dart b/test/canonical_group_rides_social_responsive_test.dart index af38d1e..b08a08b 100644 --- a/test/canonical_group_rides_social_responsive_test.dart +++ b/test/canonical_group_rides_social_responsive_test.dart @@ -22,6 +22,8 @@ import 'package:shared_preferences/shared_preferences.dart'; import 'package:motorcycle_dash/features/records/records_service.dart'; class _FakeAuth implements AuthService { + @override Future ensureBackendIdentity() async {} + final _states = StreamController.broadcast(); AuthUser? user; diff --git a/test/canonical_settings_account_pro_responsive_test.dart b/test/canonical_settings_account_pro_responsive_test.dart index 5e1cdfc..501ec36 100644 --- a/test/canonical_settings_account_pro_responsive_test.dart +++ b/test/canonical_settings_account_pro_responsive_test.dart @@ -22,6 +22,8 @@ import 'package:drift/native.dart'; import 'package:shared_preferences/shared_preferences.dart'; class _FakeAuth implements AuthService { + @override Future ensureBackendIdentity() async {} + final _states = StreamController.broadcast(); AuthUser? user; diff --git a/test/canonical_whole_app_regression_matrix_test.dart b/test/canonical_whole_app_regression_matrix_test.dart index 3be7d07..cc13701 100644 --- a/test/canonical_whole_app_regression_matrix_test.dart +++ b/test/canonical_whole_app_regression_matrix_test.dart @@ -84,6 +84,8 @@ const Map imeViewports = { }; class _FakeAuth implements AuthService { + @override Future ensureBackendIdentity() async {} + AuthUser? user; final _states = StreamController.broadcast(); diff --git a/test/cloud_restore_manual_test.dart b/test/cloud_restore_manual_test.dart index 5ec273d..edc98d6 100644 --- a/test/cloud_restore_manual_test.dart +++ b/test/cloud_restore_manual_test.dart @@ -24,6 +24,7 @@ class _TestAuthService implements AuthService { @override Future reloadCurrentUser() async {} @override Future reauthenticate({String? password}) async {} @override Future deleteCurrentUser() async {} + @override Future ensureBackendIdentity() async {} AuthUser? _user; String? token = 'token'; bool configured = true; diff --git a/test/cloud_sync_hardening_test.dart b/test/cloud_sync_hardening_test.dart index 1398e6e..689256e 100644 --- a/test/cloud_sync_hardening_test.dart +++ b/test/cloud_sync_hardening_test.dart @@ -21,6 +21,7 @@ class TestMockAuthService implements AuthService { @override Future reloadCurrentUser() async {} @override Future reauthenticate({String? password}) async {} @override Future deleteCurrentUser() async {} + @override Future ensureBackendIdentity() async {} AuthUser? _user; AuthState _state = const AuthState.unauthenticated(); diff --git a/test/firebase_sync_test.dart b/test/firebase_sync_test.dart index 95d9260..86774ec 100644 --- a/test/firebase_sync_test.dart +++ b/test/firebase_sync_test.dart @@ -28,6 +28,7 @@ class MockAuthService implements AuthService { @override Future reloadCurrentUser() async {} @override Future reauthenticate({String? password}) async {} @override Future deleteCurrentUser() async {} + @override Future ensureBackendIdentity() async {} AuthUser? _user; AuthState _state = const AuthState.unauthenticated(); diff --git a/test/ownership_isolation_test.dart b/test/ownership_isolation_test.dart index bb61193..0df1cac 100644 --- a/test/ownership_isolation_test.dart +++ b/test/ownership_isolation_test.dart @@ -17,6 +17,7 @@ class _Auth implements AuthService { @override Future reloadCurrentUser() async {} @override Future reauthenticate({String? password}) async {} @override Future deleteCurrentUser() async {} + @override Future ensureBackendIdentity() async {} AuthUser? user; @override bool get isConfigured => true; @override AuthUser? get currentUser => user; diff --git a/test/r2_security_audit_test.dart b/test/r2_security_audit_test.dart index 2304ddb..952c80d 100644 --- a/test/r2_security_audit_test.dart +++ b/test/r2_security_audit_test.dart @@ -20,6 +20,7 @@ class MockSecurityAuthService implements AuthService { @override Future reloadCurrentUser() async {} @override Future reauthenticate({String? password}) async {} @override Future deleteCurrentUser() async {} + @override Future ensureBackendIdentity() async {} AuthUser? _user; String? _token; diff --git a/test/social_screens_widget_test.dart b/test/social_screens_widget_test.dart index 273ef7e..ec2074d 100644 --- a/test/social_screens_widget_test.dart +++ b/test/social_screens_widget_test.dart @@ -14,6 +14,8 @@ import 'package:motorcycle_dash/services/social/social_providers.dart'; import 'package:shared_preferences/shared_preferences.dart'; class _FakeAuth implements AuthService { + @override Future ensureBackendIdentity() async {} + final _states = StreamController.broadcast(); AuthUser? user; diff --git a/test/valhalla_route_provider_test.dart b/test/valhalla_route_provider_test.dart index 0f3c81a..fe40445 100644 --- a/test/valhalla_route_provider_test.dart +++ b/test/valhalla_route_provider_test.dart @@ -1,3 +1,5 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:motorcycle_dash/services/routes/route_providers.dart'; import 'dart:convert'; import 'package:flutter_test/flutter_test.dart'; import 'package:http/http.dart' as http; @@ -108,7 +110,7 @@ void main() { 'distanceMeters': 18500.5, 'durationSeconds': 1320, 'warnings': ['Motorcycle route preview'], - 'attribution': '© Stadia Maps', + 'attribution': '© Stadia Maps', }), 200, headers: {'content-type': 'application/json'}, @@ -257,5 +259,125 @@ void main() { )), ); }); + test('routing request after bootstrap sends Bearer token', () async { + var tokenProviderCalled = false; + final mockClient = MockClient((request) async { + expect(request.headers['authorization'], 'Bearer my-bootstrap-token'); + return http.Response(jsonEncode({ + 'encodedPolyline': 'w~j_~A_{n~P', + 'distanceMeters': 1234.5, + 'durationSeconds': 600, + 'warnings': [] + }), 200); + }); + + final provider = ValhallaRouteProvider( + client: mockClient, + tokenProvider: () async { + tokenProviderCalled = true; + return 'my-bootstrap-token'; + }, + ); + + final stops = [ + const RouteStop(id: '1', displayName: '1', latitude: 10, longitude: 10, order: 0), + const RouteStop(id: '2', displayName: '2', latitude: 20, longitude: 20, order: 1), + ]; + + await provider.calculateRoute(stops); + expect(tokenProviderCalled, true); + }); + + test('search request after bootstrap sends Bearer token', () async { + var tokenProviderCalled = false; + final mockClient = MockClient((request) async { + expect(request.headers['authorization'], 'Bearer search-bootstrap-token'); + return http.Response(jsonEncode({ + 'results': [], + 'attribution': 'Test' + }), 200); + }); + + final provider = ValhallaRouteProvider( + client: mockClient, + tokenProvider: () async { + tokenProviderCalled = true; + return 'search-bootstrap-token'; + }, + ); + + await provider.searchPlaces('cafe'); + expect(tokenProviderCalled, true); + }); + + test('resolve request after bootstrap sends Bearer token', () async { + var tokenProviderCalled = false; + final mockClient = MockClient((request) async { + expect(request.headers['authorization'], 'Bearer resolve-bootstrap-token'); + return http.Response(jsonEncode({ + 'providerId': 'place_123', + 'displayName': 'A Place', + 'latitude': 10.0, + 'longitude': 10.0 + }), 200); + }); + + final provider = ValhallaRouteProvider( + client: mockClient, + tokenProvider: () async { + tokenProviderCalled = true; + return 'resolve-bootstrap-token'; + }, + ); + + await provider.resolvePlace('place_123'); + expect(tokenProviderCalled, true); + }); + }); + + group('Route Provider Configuration and Activation Tests', () { + test('configured backendUrl is honored', () { + final customProvider = ValhallaRouteProvider( + backendUrl: 'http://custom-host:8080', + tokenProvider: () async => 'token', + ); + expect(customProvider.backendUrl, 'http://custom-host:8080'); + }); + + test('activeRouteCalculationProvider resolves to ValhallaRouteProvider by default', () { + final container = ProviderContainer(); + addTearDown(container.dispose); + final activeProvider = container.read(activeRouteCalculationProvider); + expect(activeProvider, isA()); + expect(activeProvider.name, 'Stadia Maps (Valhalla)'); + }); + + test('provider errors remain explicit and do not silently fall back to straight line', () async { + final mockClient = MockClient((request) async { + return http.Response(jsonEncode({ + 'error': 'providerUnavailable', + 'message': 'Routing server unavailable' + }), 503); + }); + + final provider = ValhallaRouteProvider( + client: mockClient, + tokenProvider: () async => 'token', + ); + + final stops = [ + const RouteStop(id: '1', displayName: '1', latitude: 10, longitude: 10, order: 0), + const RouteStop(id: '2', displayName: '2', latitude: 20, longitude: 20, order: 1), + ]; + + expect( + () => provider.calculateRoute(stops), + throwsA(isA().having( + (e) => e.code, + 'code', + 'providerUnavailable', + )), + ); + }); }); }