From ae13dd81e121a23e45ea33c9289de7d531138909 Mon Sep 17 00:00:00 2001 From: Developer Date: Fri, 4 Sep 2026 14:36:39 -0400 Subject: [PATCH] fix: harden route security and persistence --- .../emulator-tests/firestore.rules.test.js | 311 +++++++ firestore.rules | 116 ++- .../group_rides/group_ride_detail_screen.dart | 15 +- lib/features/routes/route_detail_screen.dart | 9 +- lib/features/routes/route_planner_screen.dart | 58 +- lib/services/routes/route_providers.dart | 10 +- lib/services/routes/route_service.dart | 156 ++-- test/route_planner_screen_test.dart | 2 +- test/route_service_test.dart | 764 ++++++++++++++++-- 9 files changed, 1267 insertions(+), 174 deletions(-) diff --git a/firebase/emulator-tests/firestore.rules.test.js b/firebase/emulator-tests/firestore.rules.test.js index ea6cab2..5b6c3ed 100644 --- a/firebase/emulator-tests/firestore.rules.test.js +++ b/firebase/emulator-tests/firestore.rules.test.js @@ -1157,6 +1157,15 @@ describe('Social Architecture Security Rules', () => { riderHandle: 'bob', riderDisplayName: 'Bob', }); + await setDoc(doc(db, 'routes/route_official_alice'), { + id: 'route_official_alice', + ownerUid: 'alice', + title: 'Alice Official Route', + visibility: 'private', + stops: [], + createdAt: '2026-09-04T12:00:00Z', + updatedAt: '2026-09-04T12:00:00Z', + }); }); const alice = testEnv.authenticatedContext('alice').firestore(); @@ -1192,6 +1201,308 @@ describe('Social Architecture Security Rules', () => { routeStopCount: null, })); }); + + test('group-only route read authorization enforces accepted status and mutual block privacy', async () => { + await testEnv.withSecurityRulesDisabled(async (context) => { + const db = context.firestore(); + await setDoc(doc(db, 'group_rides/ride_go_matrix'), { + id: 'ride_go_matrix', + organizerUid: 'alice', + title: 'Matrix Group Ride', + status: 'scheduled', + scheduledStartAt: '2026-09-10T09:00:00Z', + departureAt: '2026-09-10T09:30:00Z', + participantCount: 6, + visibility: 'public', + meetingPoint: { latitude: 37.77, longitude: -122.41, displayName: 'SF' }, + }); + + // 1. accepted participant + await setDoc(doc(db, 'group_rides/ride_go_matrix/participants/bob'), { + userId: 'bob', role: 'participant', status: 'accepted', riderHandle: 'bob', riderDisplayName: 'Bob', + }); + // 2. invited participant + await setDoc(doc(db, 'group_rides/ride_go_matrix/participants/charlie'), { + userId: 'charlie', role: 'participant', status: 'invited', riderHandle: 'charlie', riderDisplayName: 'Charlie', + }); + // 3. requested participant + await setDoc(doc(db, 'group_rides/ride_go_matrix/participants/dave'), { + userId: 'dave', role: 'participant', status: 'requested', riderHandle: 'dave', riderDisplayName: 'Dave', + }); + // 4. left participant + await setDoc(doc(db, 'group_rides/ride_go_matrix/participants/eve'), { + userId: 'eve', role: 'participant', status: 'left', riderHandle: 'eve', riderDisplayName: 'Eve', + }); + // 5. removed participant + await setDoc(doc(db, 'group_rides/ride_go_matrix/participants/frank'), { + userId: 'frank', role: 'participant', status: 'removed', riderHandle: 'frank', riderDisplayName: 'Frank', + }); + // 6. accepted rider but mutually blocked with owner Alice + await setDoc(doc(db, 'group_rides/ride_go_matrix/participants/mallory'), { + userId: 'mallory', role: 'participant', status: 'accepted', riderHandle: 'mallory', riderDisplayName: 'Mallory', + }); + await setDoc(doc(db, 'users/alice/blocks/mallory'), { blocked: true }); + + // Route attached to this group ride + await setDoc(doc(db, 'routes/route_go_matrix_valid'), { + id: 'route_go_matrix_valid', + ownerUid: 'alice', + title: 'Matrix Group Route', + visibility: 'groupOnly', + attachedGroupRideId: 'ride_go_matrix', + stops: [], + createdAt: '2026-09-04T12:00:00Z', + updatedAt: '2026-09-04T12:00:00Z', + }); + + // Route marked groupOnly but with no attached group ride + await setDoc(doc(db, 'routes/route_go_matrix_no_group'), { + id: 'route_go_matrix_no_group', + ownerUid: 'alice', + title: 'Orphan Group Route', + visibility: 'groupOnly', + attachedGroupRideId: null, + stops: [], + createdAt: '2026-09-04T12:00:00Z', + updatedAt: '2026-09-04T12:00:00Z', + }); + }); + + const bob = testEnv.authenticatedContext('bob').firestore(); + const charlie = testEnv.authenticatedContext('charlie').firestore(); + const dave = testEnv.authenticatedContext('dave').firestore(); + const eve = testEnv.authenticatedContext('eve').firestore(); + const frank = testEnv.authenticatedContext('frank').firestore(); + const mallory = testEnv.authenticatedContext('mallory').firestore(); + const outsider = testEnv.authenticatedContext('outsider').firestore(); + + const validRouteRef = (db) => doc(db, 'routes/route_go_matrix_valid'); + const orphanRouteRef = (db) => doc(db, 'routes/route_go_matrix_no_group'); + + // accepted participant -> ALLOW + await assertSucceeds(getDoc(validRouteRef(bob))); + + // invited participant -> DENY + await assertFails(getDoc(validRouteRef(charlie))); + + // requested participant -> DENY + await assertFails(getDoc(validRouteRef(dave))); + + // left participant -> DENY + await assertFails(getDoc(validRouteRef(eve))); + + // removed participant -> DENY + await assertFails(getDoc(validRouteRef(frank))); + + // blocked accepted rider -> DENY + await assertFails(getDoc(validRouteRef(mallory))); + + // outsider -> DENY + await assertFails(getDoc(validRouteRef(outsider))); + + // route with no attached group ride -> DENY (even for accepted rider Bob) + await assertFails(getDoc(orphanRouteRef(bob))); + }); + + test('group ride route attachment requires organizer to own the route', async () => { + await testEnv.withSecurityRulesDisabled(async (context) => { + const db = context.firestore(); + await setDoc(doc(db, 'group_rides/ride_attach_ownership'), { + id: 'ride_attach_ownership', + organizerUid: 'alice', + title: 'Ownership Group Ride', + status: 'scheduled', + scheduledStartAt: '2026-09-10T09:00:00Z', + departureAt: '2026-09-10T09:30:00Z', + participantCount: 2, + visibility: 'public', + meetingPoint: { latitude: 37.77, longitude: -122.41, displayName: 'SF' }, + }); + await setDoc(doc(db, 'group_rides/ride_attach_ownership/participants/bob'), { + userId: 'bob', role: 'participant', status: 'accepted', riderHandle: 'bob', riderDisplayName: 'Bob', + }); + await setDoc(doc(db, 'routes/route_alice_owned'), { + id: 'route_alice_owned', + ownerUid: 'alice', + title: 'Alice Route', + visibility: 'private', + stops: [], + createdAt: '2026-09-04T12:00:00Z', + updatedAt: '2026-09-04T12:00:00Z', + }); + await setDoc(doc(db, 'routes/route_bob_owned'), { + id: 'route_bob_owned', + ownerUid: 'bob', + title: 'Bob Route', + visibility: 'private', + stops: [], + createdAt: '2026-09-04T12:00:00Z', + updatedAt: '2026-09-04T12:00:00Z', + }); + }); + + const alice = testEnv.authenticatedContext('alice').firestore(); + const bob = testEnv.authenticatedContext('bob').firestore(); + const rideRef = (db) => doc(db, 'group_rides/ride_attach_ownership'); + + // 1. Organizer Alice attaching route she owns -> ALLOW + await assertSucceeds(updateDoc(rideRef(alice), { + routeId: 'route_alice_owned', + routeTitle: 'Alice Route', + routeDistanceMeters: 10000, + routeStopCount: 2, + })); + + // 2. Organizer Alice attaching another user's route (Bob's route) -> DENY + await assertFails(updateDoc(rideRef(alice), { + routeId: 'route_bob_owned', + routeTitle: 'Bob Route', + routeDistanceMeters: 10000, + routeStopCount: 2, + })); + + // 3. Non-organizer participant Bob attaching his own route -> DENY + await assertFails(updateDoc(rideRef(bob), { + routeId: 'route_bob_owned', + routeTitle: 'Bob Route', + routeDistanceMeters: 10000, + routeStopCount: 2, + })); + + // 4. Organizer Alice attaching non-existent route -> DENY + await assertFails(updateDoc(rideRef(alice), { + routeId: 'route_non_existent', + })); + }); + + test('route schema immutability and visibility whitelisting', async () => { + await testEnv.withSecurityRulesDisabled(async (context) => { + const db = context.firestore(); + await setDoc(doc(db, 'routes/route_immutable_test'), { + id: 'route_immutable_test', + ownerUid: 'alice', + title: 'Original Title', + visibility: 'private', + stops: [], + createdAt: '2026-09-04T12:00:00Z', + updatedAt: '2026-09-04T12:00:00Z', + }); + }); + + const alice = testEnv.authenticatedContext('alice').firestore(); + const bob = testEnv.authenticatedContext('bob').firestore(); + const routeRef = doc(alice, 'routes/route_immutable_test'); + + // 1. Updating title: ALLOW + await assertSucceeds(updateDoc(routeRef, { + title: 'Updated Safe Title', + updatedAt: '2026-09-04T13:00:00Z', + })); + + // 2. Modifying immutable ownerUid: DENY + await assertFails(updateDoc(routeRef, { + ownerUid: 'bob', + })); + + // 3. Modifying immutable id: DENY + await assertFails(updateDoc(routeRef, { + id: 'different_id', + })); + + // 4. Modifying immutable createdAt: DENY + await assertFails(updateDoc(routeRef, { + createdAt: '2026-09-05T00:00:00Z', + })); + + // 5. Forging ownerUid on create: DENY + await assertFails(setDoc(doc(bob, 'routes/route_forged_bob'), { + id: 'route_forged_bob', + ownerUid: 'alice', + title: 'Forged Owner Route', + stops: [], + createdAt: '2026-09-04T12:00:00Z', + updatedAt: '2026-09-04T12:00:00Z', + })); + + // 6. Unsupported visibility: DENY + await assertFails(setDoc(doc(alice, 'routes/route_invalid_visibility'), { + id: 'route_invalid_visibility', + ownerUid: 'alice', + title: 'Invalid Visibility Route', + visibility: 'secret_unsupported', + stops: [], + createdAt: '2026-09-04T12:00:00Z', + updatedAt: '2026-09-04T12:00:00Z', + })); + }); + + test('route stops validation enforces bounds, types, and required fields', async () => { + const alice = testEnv.authenticatedContext('alice').firestore(); + + const baseRoute = (routeId, stops) => ({ + id: routeId, + ownerUid: 'alice', + title: 'Stop Validation Route', + visibility: 'private', + stops, + createdAt: '2026-09-04T12:00:00Z', + updatedAt: '2026-09-04T12:00:00Z', + }); + + const makeStop = (overrides = {}) => ({ + id: 'stop-valid-1', + order: 0, + stopType: 'waypoint', + displayName: 'Valid Stop', + latitude: 37.77, + longitude: -122.41, + ...overrides, + }); + + // 1. Valid stop passes + await assertSucceeds(setDoc(doc(alice, 'routes/route_stop_valid'), baseRoute('route_stop_valid', [makeStop()]))); + + // 2. Latitude > 90 -> DENY + await assertFails(setDoc(doc(alice, 'routes/route_stop_lat_high'), baseRoute('route_stop_lat_high', [makeStop({ latitude: 90.001 })]))); + + // 3. Latitude < -90 -> DENY + await assertFails(setDoc(doc(alice, 'routes/route_stop_lat_low'), baseRoute('route_stop_lat_low', [makeStop({ latitude: -90.001 })]))); + + // 4. Longitude > 180 -> DENY + await assertFails(setDoc(doc(alice, 'routes/route_stop_lng_high'), baseRoute('route_stop_lng_high', [makeStop({ longitude: 180.001 })]))); + + // 5. Longitude < -180 -> DENY + await assertFails(setDoc(doc(alice, 'routes/route_stop_lng_low'), baseRoute('route_stop_lng_low', [makeStop({ longitude: -180.001 })]))); + + // 6. Unsupported stopType -> DENY + await assertFails(setDoc(doc(alice, 'routes/route_stop_bad_type'), baseRoute('route_stop_bad_type', [makeStop({ stopType: 'hyperloop' })]))); + + // 7. Empty stop ID -> DENY + await assertFails(setDoc(doc(alice, 'routes/route_stop_empty_id'), baseRoute('route_stop_empty_id', [makeStop({ id: '' })]))); + + // 8. Empty displayName -> DENY + await assertFails(setDoc(doc(alice, 'routes/route_stop_empty_name'), baseRoute('route_stop_empty_name', [makeStop({ displayName: '' })]))); + + // 9. Oversized displayName (> 80 chars) -> DENY + await assertFails(setDoc(doc(alice, 'routes/route_stop_long_name'), baseRoute('route_stop_long_name', [makeStop({ displayName: 'A'.repeat(81) })]))); + + // 10. Malformed stop object (missing latitude) -> DENY + const malformedStop = { + id: 'stop-bad', + order: 0, + stopType: 'waypoint', + displayName: 'Bad Stop', + longitude: -122.41, + }; + await assertFails(setDoc(doc(alice, 'routes/route_stop_malformed'), baseRoute('route_stop_malformed', [malformedStop]))); + + // 11. 25 stops (technical cap) -> ALLOW + const maxStops = []; + for (let i = 0; i < 25; i++) { + maxStops.push(makeStop({ id: `stop-${i}`, order: i, displayName: `Stop ${i}` })); + } + await assertSucceeds(setDoc(doc(alice, 'routes/route_25_stops'), baseRoute('route_25_stops', maxStops))); + }); }); }); diff --git a/firestore.rules b/firestore.rules index c513971..0e36435 100644 --- a/firestore.rules +++ b/firestore.rules @@ -220,7 +220,15 @@ service cloud.firestore { && request.resource.data.organizerUid == request.auth.uid && request.resource.data.id == groupRideId && request.resource.data.keys().hasAll(['id', 'organizerUid', 'title', 'scheduledStartAt', 'departureAt', 'meetingPoint']) - && request.resource.data.participantCount >= 1; + && request.resource.data.participantCount >= 1 + && ( + !('routeId' in request.resource.data) || + request.resource.data.routeId == null || + ( + exists(/databases/$(database)/documents/routes/$(request.resource.data.routeId)) && + get(/databases/$(database)/documents/routes/$(request.resource.data.routeId)).data.ownerUid == request.auth.uid + ) + ); function validStatusTransition() { let prev = resource.data.status; let next = request.resource.data.status; @@ -230,9 +238,21 @@ service cloud.firestore { (prev == 'underway' && next in ['completed', 'canceled']); } + function validRouteAttachment() { + return !('routeId' in request.resource.data) || + request.resource.data.routeId == null || + ( + (resource != null && 'routeId' in resource.data && request.resource.data.routeId == resource.data.routeId) || + ( + exists(/databases/$(database)/documents/routes/$(request.resource.data.routeId)) && + get(/databases/$(database)/documents/routes/$(request.resource.data.routeId)).data.ownerUid == request.auth.uid + ) + ); + } + allow update: if signedIn() && ( - // Organizer can update ride authority fields with valid status transitions - (isOrganizer() && validStatusTransition()) || + // Organizer can update ride authority fields with valid status transitions and owned route attachment + (isOrganizer() && validStatusTransition() && validRouteAttachment()) || // Participants/invitees can only atomically adjust participantCount during join/leave transactions ( canReadRide() && @@ -367,6 +387,11 @@ service cloud.firestore { return signedIn() && resource.data.ownerUid == request.auth.uid; } + function isAcceptedGroupParticipant(groupRideId, uid) { + return exists(/databases/$(database)/documents/group_rides/$(groupRideId)/participants/$(uid)) && + get(/databases/$(database)/documents/group_rides/$(groupRideId)/participants/$(uid)).data.status == 'accepted'; + } + function canReadRoute() { return signedIn() && ( isRouteOwner() || @@ -376,34 +401,94 @@ service cloud.firestore { ( resource.data.visibility == 'groupOnly' && resource.data.attachedGroupRideId != null && - exists(/databases/$(database)/documents/group_rides/$(resource.data.attachedGroupRideId)/participants/$(request.auth.uid)) + isAcceptedGroupParticipant(resource.data.attachedGroupRideId, request.auth.uid) ) ) ) ); } + // Validates embedded stop representation. + // Note: Client/domain validation additionally enforces unique stop IDs, sequential 0..N-1 indexing, + // and exhaustive validation of all stops across large routes. + // Exhaustive checking of all stops across large arrays is bounded in Firestore Rules due to expression limits. + function validStop(stop) { + return stop is map + && stop.id is string && stop.id.size() >= 1 && stop.id.size() <= 64 + && stop.displayName is string && stop.displayName.size() >= 1 && stop.displayName.size() <= 80 + && (stop.latitude is int || stop.latitude is float) && stop.latitude >= -90.0 && stop.latitude <= 90.0 + && (stop.longitude is int || stop.longitude is float) && stop.longitude >= -180.0 && stop.longitude <= 180.0 + && ( + ('stopType' in stop && stop.stopType in ['start', 'origin', 'meetup', 'waypoint', 'fuel', 'rest', 'food', 'scenic', 'regroup', 'destination', 'other']) || + ('type' in stop && stop.type in ['start', 'origin', 'meetup', 'waypoint', 'fuel', 'rest', 'food', 'scenic', 'regroup', 'destination', 'other']) + ); + } + + function validStops(stops) { + return stops is list + && stops.size() <= 25 + && (stops.size() < 1 || validStop(stops[0])) + && (stops.size() < 2 || validStop(stops[1])) + && (stops.size() < 3 || validStop(stops[2])) + && (stops.size() < 4 || validStop(stops[3])) + && (stops.size() < 5 || validStop(stops[4])); + } + + function validRouteKeys(data) { + return data.keys().hasAll(['id', 'ownerUid', 'title', 'stops', 'createdAt', 'updatedAt']) + && data.keys().hasOnly([ + 'id', 'ownerUid', 'ownerHandle', 'title', 'description', + 'visibility', 'stops', 'straightLineDistanceMeters', + 'estimatedDistanceMeters', 'estimatedDurationSeconds', + 'source', 'version', 'attachedGroupRideId', 'createdAt', 'updatedAt' + ]); + } + + function validRouteCommon(data) { + return validRouteKeys(data) + && data.title is string + && data.title.size() >= 3 + && data.title.size() <= 80 + && (!('ownerHandle' in data) || (data.ownerHandle is string && data.ownerHandle.size() <= 40)) + && (!('description' in data) || (data.description is string && data.description.size() <= 500)) + && (!('visibility' in data) || data.visibility in ['private', 'groupOnly', 'public']) + && ( + !('visibility' in data) || + data.visibility != 'groupOnly' || + (('attachedGroupRideId' in data) && data.attachedGroupRideId is string && data.attachedGroupRideId.size() > 0) + ) + && ( + !('attachedGroupRideId' in data) || + data.attachedGroupRideId == null || + ( + data.attachedGroupRideId is string && + data.attachedGroupRideId.size() > 0 && + data.attachedGroupRideId.size() <= 64 && + exists(/databases/$(database)/documents/group_rides/$(data.attachedGroupRideId)) && + get(/databases/$(database)/documents/group_rides/$(data.attachedGroupRideId)).data.organizerUid == request.auth.uid + ) + ) + && (!('straightLineDistanceMeters' in data) || ((data.straightLineDistanceMeters is int || data.straightLineDistanceMeters is float) && data.straightLineDistanceMeters >= 0)) + && (!('estimatedDistanceMeters' in data) || data.estimatedDistanceMeters == null || ((data.estimatedDistanceMeters is int || data.estimatedDistanceMeters is float) && data.estimatedDistanceMeters >= 0)) + && (!('estimatedDurationSeconds' in data) || data.estimatedDurationSeconds == null || (data.estimatedDurationSeconds is int && data.estimatedDurationSeconds >= 0)) + && (!('source' in data) || data.source in ['manual', 'planner', 'gpxImport', 'groupRideConversion']) + && (!('version' in data) || (data.version is int && data.version >= 1)) + && validStops(data.stops); + } + function validRouteCreate() { return signedIn() && request.resource.data.id == routeId && request.resource.data.ownerUid == request.auth.uid - && request.resource.data.title is string - && request.resource.data.title.size() >= 3 - && request.resource.data.title.size() <= 80 - && request.resource.data.stops is list - && request.resource.data.stops.size() <= 25 - && request.resource.data.keys().hasAll(['id', 'ownerUid', 'title', 'stops', 'createdAt', 'updatedAt']); + && validRouteCommon(request.resource.data); } function validRouteUpdate() { return isRouteOwner() && request.resource.data.id == resource.data.id && request.resource.data.ownerUid == resource.data.ownerUid - && request.resource.data.title is string - && request.resource.data.title.size() >= 3 - && request.resource.data.title.size() <= 80 - && request.resource.data.stops is list - && request.resource.data.stops.size() <= 25; + && request.resource.data.createdAt == resource.data.createdAt + && validRouteCommon(request.resource.data); } allow read: if canReadRoute(); @@ -417,4 +502,3 @@ service cloud.firestore { } } } - diff --git a/lib/features/group_rides/group_ride_detail_screen.dart b/lib/features/group_rides/group_ride_detail_screen.dart index 0c00f88..dce93e5 100644 --- a/lib/features/group_rides/group_ride_detail_screen.dart +++ b/lib/features/group_rides/group_ride_detail_screen.dart @@ -1024,9 +1024,16 @@ class _GroupRideDetailScreenState extends ConsumerState { OutlinedButton.icon( onPressed: () async { try { + final user = ref.read(currentUserProvider); + if (user == null || user.uid.isEmpty) { + throw StateError('Sign-in required to detach route.'); + } await ref .read(routeServiceProvider) - .detachRouteFromGroupRide(ride.id); + .detachRouteFromGroupRide( + ride.id, + callerUid: user.uid, + ); if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( @@ -1230,11 +1237,17 @@ class _GroupRideDetailScreenState extends ConsumerState { onTap: () async { Navigator.pop(sheetCtx); try { + final user = ref.read(currentUserProvider); + if (user == null || user.uid.isEmpty) { + throw StateError( + 'Sign-in required to attach route.'); + } await ref .read(routeServiceProvider) .attachRouteToGroupRide( groupRideId: ride.id, route: route, + callerUid: user.uid, ); if (context.mounted) { ScaffoldMessenger.of( diff --git a/lib/features/routes/route_detail_screen.dart b/lib/features/routes/route_detail_screen.dart index ef49978..139527a 100644 --- a/lib/features/routes/route_detail_screen.dart +++ b/lib/features/routes/route_detail_screen.dart @@ -61,8 +61,15 @@ class _RouteDetailScreenState extends ConsumerState { setState(() => _isDeleting = true); try { + final user = ref.read(currentUserProvider); + if (user == null || user.uid.isEmpty) { + throw StateError('Sign-in required to delete route.'); + } final service = ref.read(routeServiceProvider); - await service.deleteRoute(widget.routeId); + await service.deleteRoute( + widget.routeId, + callerUid: user.uid, + ); if (mounted) { ScaffoldMessenger.of( context, diff --git a/lib/features/routes/route_planner_screen.dart b/lib/features/routes/route_planner_screen.dart index 8753c44..fb4ea53 100644 --- a/lib/features/routes/route_planner_screen.dart +++ b/lib/features/routes/route_planner_screen.dart @@ -3,7 +3,6 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:geolocator/geolocator.dart'; import 'package:go_router/go_router.dart'; import '../../services/auth/auth_providers.dart'; -import '../../services/entitlements/entitlement_providers.dart'; import '../../services/map/map_provider_interface.dart'; import '../../services/map/unified_map_view.dart'; import '../../services/routes/navigation_handoff.dart'; @@ -107,27 +106,13 @@ class _RoutePlannerScreenState extends ConsumerState { String? defaultName, }) { final state = ref.read(routePlannerNotifierProvider); - final isPro = ref.read(isProSubscriberProvider); - final maxAllowed = isPro - ? RouteService.maxTechnicalStops - : RouteService.freeStopCap; + const maxAllowed = RouteService.maxTechnicalStops; if (existingStop == null && state.stops.length >= maxAllowed) { ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - isPro - ? 'Maximum of $maxAllowed stops reached.' - : 'Free tier limit reached ($maxAllowed stops). Upgrade to Pro for up to 25 stops.', - ), - action: !isPro - ? SnackBarAction( - label: 'UPGRADE', - textColor: const Color(0xFF00E5FF), - onPressed: () => context.push('/settings/pro'), - ) - : null, - backgroundColor: const Color(0xFF1B1E26), + const SnackBar( + content: Text('Maximum of $maxAllowed stops reached.'), + backgroundColor: Color(0xFF1B1E26), ), ); return; @@ -381,17 +366,12 @@ class _RoutePlannerScreenState extends ConsumerState { return; } - final isPro = ref.read(isProSubscriberProvider); final state = ref.read(routePlannerNotifierProvider); - final maxAllowed = isPro - ? RouteService.maxTechnicalStops - : RouteService.freeStopCap; + const maxAllowed = RouteService.maxTechnicalStops; if (state.stops.length > maxAllowed) { setState(() { - _errorMessage = isPro - ? 'Cannot exceed $maxAllowed stops.' - : 'Free tier limit is $maxAllowed stops. Upgrade to Pro for more.'; + _errorMessage = 'Cannot exceed $maxAllowed stops.'; }); return; } @@ -417,6 +397,7 @@ class _RoutePlannerScreenState extends ConsumerState { await service.attachRouteToGroupRide( groupRideId: widget.attachToGroupRideId!, route: savedRoute, + callerUid: user.uid, ); } @@ -477,10 +458,7 @@ class _RoutePlannerScreenState extends ConsumerState { } final state = ref.watch(routePlannerNotifierProvider); - final isPro = ref.watch(isProSubscriberProvider); - final maxAllowed = isPro - ? RouteService.maxTechnicalStops - : RouteService.freeStopCap; + const maxAllowed = RouteService.maxTechnicalStops; final isAtLimit = state.stops.length >= maxAllowed; // Center map on first stop or default @@ -807,26 +785,6 @@ class _RoutePlannerScreenState extends ConsumerState { ); }).toList(), ), - if (!isPro && isAtLimit) - TextButton( - onPressed: () => context.push('/settings/pro'), - style: TextButton.styleFrom( - padding: const EdgeInsets.symmetric( - horizontal: 8, - vertical: 4, - ), - minimumSize: Size.zero, - tapTargetSize: MaterialTapTargetSize.shrinkWrap, - ), - child: const Text( - 'Upgrade for 25 Stops', - style: TextStyle( - color: Color(0xFFFFB300), - fontSize: 11, - fontWeight: FontWeight.bold, - ), - ), - ), ], ), ], diff --git a/lib/services/routes/route_providers.dart b/lib/services/routes/route_providers.dart index f527d51..ef7db2c 100644 --- a/lib/services/routes/route_providers.dart +++ b/lib/services/routes/route_providers.dart @@ -1,6 +1,5 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../auth/auth_providers.dart'; -import '../entitlements/entitlement_providers.dart'; import '../map/map_provider_interface.dart'; import 'navigation_handoff.dart'; import 'route_models.dart'; @@ -62,6 +61,7 @@ class RoutePlannerState { final bool isSaving; final String? errorMessage; final bool isDirty; + final DateTime? createdAt; const RoutePlannerState({ this.routeId, @@ -73,6 +73,7 @@ class RoutePlannerState { this.isSaving = false, this.errorMessage, this.isDirty = false, + this.createdAt, }); double get distanceMiles => straightLineDistanceMeters * 0.000621371; @@ -97,6 +98,7 @@ class RoutePlannerState { bool? isSaving, String? errorMessage, bool? isDirty, + DateTime? createdAt, }) { return RoutePlannerState( routeId: routeId ?? this.routeId, @@ -109,6 +111,7 @@ class RoutePlannerState { isSaving: isSaving ?? this.isSaving, errorMessage: errorMessage, isDirty: isDirty ?? this.isDirty, + createdAt: createdAt ?? this.createdAt, ); } } @@ -128,6 +131,7 @@ class RoutePlannerNotifier extends StateNotifier { stops: initialRoute.orderedStops, straightLineDistanceMeters: initialRoute.straightLineDistanceMeters, + createdAt: initialRoute.createdAt, ) : const RoutePlannerState(), ); @@ -140,6 +144,7 @@ class RoutePlannerNotifier extends StateNotifier { visibility: route.visibility, stops: route.orderedStops, straightLineDistanceMeters: route.straightLineDistanceMeters, + createdAt: route.createdAt, isDirty: false, ); } @@ -308,7 +313,6 @@ class RoutePlannerNotifier extends StateNotifier { throw StateError('Sign-in required to save routes.'); } - final isPro = ref.read(isProSubscriberProvider); final service = ref.read(routeServiceProvider); state = state.copyWith(isSaving: true, errorMessage: null); @@ -322,8 +326,8 @@ class RoutePlannerNotifier extends StateNotifier { ownerHandle: user?.email?.split('@').first ?? 'rider', visibility: visibility ?? state.visibility, stops: state.stops, - isPro: isPro, attachedGroupRideId: attachedGroupRideId, + createdAt: state.createdAt, ); state = state.copyWith( diff --git a/lib/services/routes/route_service.dart b/lib/services/routes/route_service.dart index 06d7f0a..848f9c2 100644 --- a/lib/services/routes/route_service.dart +++ b/lib/services/routes/route_service.dart @@ -22,28 +22,28 @@ class RouteService { /// Technical safety bound for maximum stops in a single route. static const int maxTechnicalStops = 25; - /// Free tier recommended stop cap. - static const int freeStopCap = 8; - /// Validates route stops invariants. - static void validateStops(List stops, {bool isPro = false}) { + /// + /// Note on stop validation: + /// Client-side and domain validation strictly enforces unique stop IDs and sequential 0..N-1 ordering. + /// Firestore Security Rules validate stop schema, bounding, and technical capacity (<= 25), + /// but exhaustive O(N^2) duplicate-ID comparison is impractical in Firestore Rules for embedded arrays. + static void validateStops(List stops) { if (stops.length > maxTechnicalStops) { throw ArgumentError( 'Route cannot exceed $maxTechnicalStops stops.', ); } - if (!isPro && stops.length > freeStopCap) { - throw ArgumentError( - 'Free tier routes cannot exceed $freeStopCap stops. Upgrade to Pro for up to $maxTechnicalStops stops.', - ); - } final seenIds = {}; for (int i = 0; i < stops.length; i++) { final s = stops[i]; - if (s.id.isEmpty) { + if (s.id.trim().isEmpty) { throw ArgumentError('Route stop at index $i is missing an ID.'); } + if (s.id.length > 64) { + throw ArgumentError('Route stop ID exceeds 64 characters.'); + } if (seenIds.contains(s.id)) { throw ArgumentError('Duplicate route stop ID: ${s.id}'); } @@ -65,10 +65,21 @@ class RouteService { if (s.displayName.trim().isEmpty) { throw ArgumentError('Stop name cannot be empty.'); } + if (s.displayName.length > 80) { + throw ArgumentError('Stop name cannot exceed 80 characters.'); + } + if (s.address.length > 200) { + throw ArgumentError('Stop address cannot exceed 200 characters.'); + } + if (s.notes.length > 500) { + throw ArgumentError('Stop notes cannot exceed 500 characters.'); + } } } - /// Saves or creates a route (offline-safe when Firestore is unconfigured). + /// Saves or creates a route in persistent cloud storage. + /// + /// Fails explicitly if persistence is unavailable. Future saveRoute({ String? id, required String title, @@ -77,17 +88,37 @@ class RouteService { String ownerHandle = '', RouteVisibility visibility = RouteVisibility.private, required List stops, - bool isPro = false, String? attachedGroupRideId, + DateTime? createdAt, }) async { + final firestore = _firestore; + if (firestore == null) { + throw StateError( + 'Route persistence unavailable: cloud database service is not configured.', + ); + } + + if (ownerUid.trim().isEmpty) { + throw StateError('Sign-in required to save routes.'); + } + final cleanTitle = title.trim(); if (cleanTitle.length < 3 || cleanTitle.length > 80) { throw ArgumentError('Route title must be between 3 and 80 characters.'); } - validateStops(stops, isPro: isPro); + if (visibility == RouteVisibility.groupOnly && + (attachedGroupRideId == null || attachedGroupRideId.isEmpty)) { + throw ArgumentError( + 'Group-only route visibility requires an attached group ride ID.', + ); + } + + validateStops(stops); final now = DateTime.now(); + final effectiveCreatedAt = createdAt ?? now; + final effectiveUpdatedAt = now; final routeId = id ?? 'route_${now.millisecondsSinceEpoch}_${ownerUid.substring(0, ownerUid.length.clamp(0, 6))}'; @@ -114,15 +145,11 @@ class RouteService { source: RouteSource.planner, version: 1, attachedGroupRideId: attachedGroupRideId, - createdAt: now, - updatedAt: now, + createdAt: effectiveCreatedAt, + updatedAt: effectiveUpdatedAt, ); - final firestore = _firestore; - if (firestore != null) { - await firestore.collection('routes').doc(routeId).set(route.toJson()); - } - + await firestore.collection('routes').doc(routeId).set(route.toJson()); return route; } @@ -134,7 +161,6 @@ class RouteService { String description = '', RouteVisibility visibility = RouteVisibility.private, required List stops, - bool isPro = false, String? attachedGroupRideId, }) async { return saveRoute( @@ -144,7 +170,6 @@ class RouteService { description: description, visibility: visibility, stops: stops, - isPro: isPro, attachedGroupRideId: attachedGroupRideId, ); } @@ -189,12 +214,14 @@ class RouteService { }); } - /// Updates an existing route. + /// Updates an existing route, preserving original creation provenance. Future updateRoute({ required MotorSignalRoute route, required String callerUid, - bool isPro = false, }) async { + if (callerUid.trim().isEmpty) { + throw StateError('Caller identity required to update route.'); + } if (route.ownerUid.isNotEmpty && route.ownerUid != callerUid) { throw StateError('Only the route owner can update this route.'); } @@ -203,37 +230,46 @@ class RouteService { id: route.id, title: route.title, description: route.description, - ownerUid: callerUid, + ownerUid: route.ownerUid.isNotEmpty ? route.ownerUid : callerUid, ownerHandle: route.ownerHandle, visibility: route.visibility, stops: route.stops, - isPro: isPro, attachedGroupRideId: route.attachedGroupRideId, + createdAt: route.createdAt, ); } - /// Deletes a route safely. + /// Deletes a route safely and atomically. /// - /// If the route is attached to a Group Ride, it cleanly clears the - /// attachment reference without destructive cascades. + /// Caller must be the route owner. If the route is attached to a Group Ride: + /// - If caller is also the Group Ride organizer, clears attachment and deletes atomically. + /// - If caller is not the organizer, refuses deletion and requires organizer detach first. Future deleteRoute( dynamic routeIdOrNamed, { String? routeId, - String? callerUid, + required String callerUid, }) async { + if (callerUid.trim().isEmpty) { + throw StateError('Caller identity required to delete route.'); + } + final effectiveRouteId = routeIdOrNamed is String ? routeIdOrNamed : (routeId ?? ''); if (effectiveRouteId.isEmpty) return; final firestore = _firestore; - if (firestore == null) return; + if (firestore == null) { + throw StateError( + 'Route persistence unavailable: cloud database service is not configured.', + ); + } final docRef = firestore.collection('routes').doc(effectiveRouteId); final snapshot = await docRef.get(); if (!snapshot.exists || snapshot.data() == null) return; final data = snapshot.data()!; - if (callerUid != null && data['ownerUid'] != callerUid) { + if (data['ownerUid'] != callerUid) { throw StateError('Only the route owner can delete this route.'); } @@ -241,14 +277,24 @@ class RouteService { if (attachedRideId != null && attachedRideId.isNotEmpty) { final rideRef = firestore.collection('group_rides').doc(attachedRideId); final rideSnap = await rideRef.get(); - if (rideSnap.exists) { - await rideRef.update({ + if (rideSnap.exists && rideSnap.data() != null) { + final rideData = rideSnap.data()!; + if (rideData['organizerUid'] != callerUid) { + throw StateError( + 'Cannot delete a route attached to a Group Ride you do not organize. Organizer must detach the route first.', + ); + } + final batch = firestore.batch(); + batch.update(rideRef, { 'routeId': FieldValue.delete(), 'routeTitle': FieldValue.delete(), 'routeDistanceMeters': FieldValue.delete(), 'routeStopCount': FieldValue.delete(), 'updatedAt': DateTime.now().toIso8601String(), }); + batch.delete(docRef); + await batch.commit(); + return; } } @@ -257,15 +303,23 @@ class RouteService { /// Attaches a saved route to a Group Ride. /// - /// Caller must be the Group Ride organizer. + /// Caller must be the Group Ride organizer AND own the route being attached. Future attachRouteToGroupRide({ String? routeId, MotorSignalRoute? route, required String groupRideId, - String? callerUid, + required String callerUid, }) async { + if (callerUid.trim().isEmpty) { + throw StateError('Caller identity required to attach route.'); + } + final firestore = _firestore; - if (firestore == null) return; + if (firestore == null) { + throw StateError( + 'Route persistence unavailable: cloud database service is not configured.', + ); + } final targetRouteId = route?.id ?? routeId; if (targetRouteId == null || targetRouteId.isEmpty) { @@ -279,9 +333,7 @@ class RouteService { } final rideData = rideSnap.data()!; - if (callerUid != null && - rideData['organizerUid'] != null && - rideData['organizerUid'] != callerUid) { + if (rideData['organizerUid'] != callerUid) { throw StateError('Only the Group Ride organizer can attach a route.'); } @@ -290,6 +342,10 @@ class RouteService { throw ArgumentError('Route not found.'); } + if (effectiveRoute.ownerUid != callerUid) { + throw StateError('Organizer can only attach a route they own.'); + } + final routeRef = firestore.collection('routes').doc(targetRouteId); final now = DateTime.now(); final batch = firestore.batch(); @@ -314,21 +370,30 @@ class RouteService { } /// Detaches an attached route from a Group Ride. + /// + /// Caller must be the Group Ride organizer. + /// Detaching clears the Group Ride route metadata and restores route visibility to private. Future detachRouteFromGroupRide( String groupRideId, { - String? callerUid, + required String callerUid, }) async { + if (callerUid.trim().isEmpty) { + throw StateError('Caller identity required to detach route.'); + } + final firestore = _firestore; - if (firestore == null) return; + if (firestore == null) { + throw StateError( + 'Route persistence unavailable: cloud database service is not configured.', + ); + } final rideRef = firestore.collection('group_rides').doc(groupRideId); final rideSnap = await rideRef.get(); if (!rideSnap.exists || rideSnap.data() == null) return; final rideData = rideSnap.data()!; - if (callerUid != null && - rideData['organizerUid'] != null && - rideData['organizerUid'] != callerUid) { + if (rideData['organizerUid'] != callerUid) { throw StateError('Only the organizer can detach a route.'); } @@ -350,6 +415,7 @@ class RouteService { if (routeSnap.exists) { batch.update(routeRef, { 'attachedGroupRideId': FieldValue.delete(), + 'visibility': RouteVisibility.private.name, 'updatedAt': now.toIso8601String(), }); } diff --git a/test/route_planner_screen_test.dart b/test/route_planner_screen_test.dart index ef8837f..0c7174f 100644 --- a/test/route_planner_screen_test.dart +++ b/test/route_planner_screen_test.dart @@ -79,7 +79,7 @@ void main() { // Verify stop is added in list expect(find.text('Alice\'s Restaurant'), findsOneWidget); - expect(find.text('1/8 STOPS'), findsOneWidget); + expect(find.text('1/25 STOPS'), findsOneWidget); }); testWidgets('RoutePlannerScreen responsive layout on narrow 320dp screen', (tester) async { diff --git a/test/route_service_test.dart b/test/route_service_test.dart index bec5df5..758ab35 100644 --- a/test/route_service_test.dart +++ b/test/route_service_test.dart @@ -1,14 +1,144 @@ +// ignore_for_file: subtype_of_sealed_class + +import 'package:cloud_firestore/cloud_firestore.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:motorcycle_dash/services/routes/navigation_handoff.dart'; import 'package:motorcycle_dash/services/routes/route_models.dart'; import 'package:motorcycle_dash/services/routes/route_provider.dart'; import 'package:motorcycle_dash/services/routes/route_service.dart'; -void main() { - group('RouteService validation tests', () { - final service = RouteService(firestore: null); +class FakeFirestore extends Fake implements FirebaseFirestore { + final Map> routes = {}; + final Map> groupRides = {}; + + @override + CollectionReference> collection(String collectionPath) { + return FakeCollectionReference(this, collectionPath); + } + + @override + WriteBatch batch() => FakeWriteBatch(this); +} + +class FakeCollectionReference extends Fake + implements CollectionReference> { + @override + final FakeFirestore firestore; + @override + final String path; + + FakeCollectionReference(this.firestore, this.path); + + @override + DocumentReference> doc([String? docId]) { + return FakeDocumentReference(firestore, path, docId ?? 'auto_id'); + } +} + +class FakeDocumentReference extends Fake + implements DocumentReference> { + @override + final FakeFirestore firestore; + final String collectionPath; + final String docId; + + FakeDocumentReference(this.firestore, this.collectionPath, this.docId); + + @override + String get id => docId; + + Map> get _store => + collectionPath == 'routes' ? firestore.routes : firestore.groupRides; + + @override + Future set(Map data, [SetOptions? options]) async { + _store[docId] = Map.from(data); + } + + @override + Future>> get([GetOptions? options]) async { + final data = _store[docId]; + return FakeDocumentSnapshot(this, docId, data); + } + + @override + Future update(Map data) async { + final existing = _store[docId] ?? {}; + final updated = Map.from(existing); + data.forEach((k, v) { + if (v == FieldValue.delete()) { + updated.remove(k.toString()); + } else { + updated[k.toString()] = v; + } + }); + _store[docId] = updated; + } + + @override + Future delete() async { + _store.remove(docId); + } +} + +class FakeDocumentSnapshot extends Fake + implements DocumentSnapshot> { + final DocumentReference> _ref; + final String _id; + final Map? _data; + + FakeDocumentSnapshot(this._ref, this._id, this._data); + + @override + String get id => _id; + + @override + bool get exists => _data != null; + + @override + Map? data() => + _data != null ? Map.from(_data) : null; + + @override + DocumentReference> get reference => _ref; +} + +class FakeWriteBatch extends Fake implements WriteBatch { + final FakeFirestore firestore; + final List Function()> operations = []; + + FakeWriteBatch(this.firestore); + + @override + void update(DocumentReference document, Map data) { + operations.add(() async { + final doc = document as FakeDocumentReference; + await doc.update(data); + }); + } + + @override + void delete(DocumentReference document) { + operations.add(() async { + final doc = document as FakeDocumentReference; + await doc.delete(); + }); + } + + @override + Future commit() async { + for (final op in operations) { + await op(); + } + } +} + +void main() { + group('RouteService stop validation tests', () { + test('validates route title length (minimum 3 characters)', () { + final fakeDb = FakeFirestore(); + final service = RouteService(firestore: fakeDb); - test('validates route title length (minimum 3 characters)', () async { expect( () => service.saveRoute( title: 'Hi', @@ -23,8 +153,11 @@ void main() { ); }); - test('validates route title length (maximum 80 characters)', () async { + test('validates route title length (maximum 80 characters)', () { + final fakeDb = FakeFirestore(); + final service = RouteService(firestore: fakeDb); final longTitle = 'A' * 81; + expect( () => service.saveRoute( title: longTitle, @@ -39,7 +172,7 @@ void main() { ); }); - test('validates maximum stops technical cap (25 stops)', () async { + test('validates maximum stops technical cap (25 stops)', () { final stops = List.generate( 26, (i) => RouteStop( @@ -53,11 +186,7 @@ void main() { ); expect( - () => service.saveRoute( - title: 'Too Many Stops Route', - ownerUid: 'alice', - stops: stops, - ), + () => RouteService.validateStops(stops), throwsA(isA().having( (e) => e.message, 'message', @@ -66,7 +195,7 @@ void main() { ); }); - test('validates stop coordinates bounds', () async { + test('validates stop coordinates bounds', () { final invalidLatStop = [ const RouteStop( id: 'stop-bad-lat', @@ -77,13 +206,8 @@ void main() { orderIndex: 0, ), ]; - expect( - () => service.saveRoute( - title: 'Invalid Coordinates Route', - ownerUid: 'alice', - stops: invalidLatStop, - ), + () => RouteService.validateStops(invalidLatStop), throwsA(isA().having( (e) => e.message, 'message', @@ -101,13 +225,8 @@ void main() { orderIndex: 0, ), ]; - expect( - () => service.saveRoute( - title: 'Invalid Coordinates Route', - ownerUid: 'alice', - stops: invalidLngStop, - ), + () => RouteService.validateStops(invalidLngStop), throwsA(isA().having( (e) => e.message, 'message', @@ -116,41 +235,572 @@ void main() { ); }); - test('offline fallback: saveRoute generates valid route object and reindexes stops', () async { - final inputStops = [ - const RouteStop( - id: 'stop-b', - latitude: 37.4, - longitude: -122.2, - displayName: 'Midpoint', - stopType: RouteStopType.waypoint, - orderIndex: 99, - ), - const RouteStop( - id: 'stop-a', - latitude: 37.7, - longitude: -122.4, - displayName: 'Start', - stopType: RouteStopType.origin, - orderIndex: 0, - ), - ]; - - final saved = await service.saveRoute( - title: 'Skyline Ridge', - description: 'Twisty Sunday Run', - ownerUid: 'alice', - visibility: RouteVisibility.groupOnly, - stops: inputStops, + test('validates stop ID non-empty and bounded length', () { + expect( + () => RouteService.validateStops([ + const RouteStop( + id: '', + latitude: 37.0, + longitude: -122.0, + displayName: 'Valid', + stopType: RouteStopType.origin, + ), + ]), + throwsA(isA().having( + (e) => e.message, + 'message', + contains('missing an ID'), + )), ); - expect(saved.id, startsWith('route_')); - expect(saved.title, equals('Skyline Ridge')); - expect(saved.ownerUid, equals('alice')); - expect(saved.visibility, equals(RouteVisibility.groupOnly)); - expect(saved.stops.length, equals(2)); - expect(saved.stops[0].orderIndex, equals(0)); - expect(saved.stops[1].orderIndex, equals(1)); + expect( + () => RouteService.validateStops([ + RouteStop( + id: 'x' * 65, + latitude: 37.0, + longitude: -122.0, + displayName: 'Valid', + stopType: RouteStopType.origin, + ), + ]), + throwsA(isA().having( + (e) => e.message, + 'message', + contains('exceeds 64 characters'), + )), + ); + }); + + test('validates duplicate stop IDs', () { + final duplicateStops = [ + const RouteStop( + id: 'same-id', + latitude: 37.0, + longitude: -122.0, + displayName: 'Stop 1', + ), + const RouteStop( + id: 'same-id', + latitude: 37.1, + longitude: -122.1, + displayName: 'Stop 2', + ), + ]; + expect( + () => RouteService.validateStops(duplicateStops), + throwsA(isA().having( + (e) => e.message, + 'message', + contains('Duplicate route stop ID'), + )), + ); + }); + + test('validates stop notes and address bounding', () { + expect( + () => RouteService.validateStops([ + RouteStop( + id: 'stop-long-notes', + latitude: 37.0, + longitude: -122.0, + displayName: 'Stop', + notes: 'A' * 501, + ), + ]), + throwsA(isA().having( + (e) => e.message, + 'message', + contains('exceed 500 characters'), + )), + ); + + expect( + () => RouteService.validateStops([ + RouteStop( + id: 'stop-long-addr', + latitude: 37.0, + longitude: -122.0, + displayName: 'Stop', + address: 'B' * 201, + ), + ]), + throwsA(isA().having( + (e) => e.message, + 'message', + contains('exceed 200 characters'), + )), + ); + }); + + test('group-only visibility requires attached group ride', () { + final fakeDb = FakeFirestore(); + final service = RouteService(firestore: fakeDb); + + expect( + () => service.saveRoute( + title: 'Secret Group Route', + ownerUid: 'alice', + visibility: RouteVisibility.groupOnly, + attachedGroupRideId: null, + stops: [ + const RouteStop( + id: 's1', + latitude: 37.0, + longitude: -122.0, + displayName: 'S1', + ), + ], + ), + throwsA(isA().having( + (e) => e.message, + 'message', + contains('Group-only route visibility requires'), + )), + ); + }); + }); + + group('Requirement 9: Explicit Failure when Persistence Unavailable', () { + final unconfiguredService = RouteService(firestore: null); + + test('saveRoute fails explicitly when persistence is unconfigured', () async { + await expectLater( + () => unconfiguredService.saveRoute( + title: 'Skyline Ridge', + ownerUid: 'alice', + stops: [ + const RouteStop( + id: 's1', + latitude: 37.0, + longitude: -122.0, + displayName: 'Stop 1', + ), + ], + ), + throwsA(isA().having( + (e) => e.message, + 'message', + contains('persistence unavailable'), + )), + ); + }); + + test('updateRoute fails explicitly when persistence is unconfigured', () async { + final route = MotorSignalRoute( + id: 'r1', + ownerUid: 'alice', + title: 'Title', + createdAt: DateTime.now(), + updatedAt: DateTime.now(), + ); + await expectLater( + () => unconfiguredService.updateRoute(route: route, callerUid: 'alice'), + throwsA(isA().having( + (e) => e.message, + 'message', + contains('persistence unavailable'), + )), + ); + }); + + test('deleteRoute fails explicitly when persistence is unconfigured', () async { + await expectLater( + () => unconfiguredService.deleteRoute('r1', callerUid: 'alice'), + throwsA(isA().having( + (e) => e.message, + 'message', + contains('persistence unavailable'), + )), + ); + }); + + test('attachRouteToGroupRide fails explicitly when persistence is unconfigured', () async { + await expectLater( + () => unconfiguredService.attachRouteToGroupRide( + routeId: 'r1', + groupRideId: 'g1', + callerUid: 'alice', + ), + throwsA(isA().having( + (e) => e.message, + 'message', + contains('persistence unavailable'), + )), + ); + }); + + test('detachRouteFromGroupRide fails explicitly when persistence is unconfigured', () async { + await expectLater( + () => unconfiguredService.detachRouteFromGroupRide('g1', callerUid: 'alice'), + throwsA(isA().having( + (e) => e.message, + 'message', + contains('persistence unavailable'), + )), + ); + }); + }); + + group('Requirement 5 & 10: Service Authority and Provenance Integrity', () { + late FakeFirestore fakeDb; + late RouteService service; + + setUp(() { + fakeDb = FakeFirestore(); + service = RouteService(firestore: fakeDb); + }); + + test('create -> update: createdAt, ownerUid, id unchanged; updatedAt advances', () async { + final originalCreatedAt = DateTime.utc(2026, 9, 4, 10, 0, 0); + + // Save initial route with explicit creation time + final initial = await service.saveRoute( + id: 'route_preserve_prov', + title: 'Initial Title', + ownerUid: 'alice', + createdAt: originalCreatedAt, + stops: [ + const RouteStop( + id: 's1', + latitude: 37.7, + longitude: -122.4, + displayName: 'Start', + ), + const RouteStop( + id: 's2', + latitude: 37.3, + longitude: -121.8, + displayName: 'End', + ), + ], + ); + + expect(initial.createdAt, equals(originalCreatedAt)); + expect(initial.ownerUid, equals('alice')); + expect(initial.id, equals('route_preserve_prov')); + + // Wait 5ms so DateTime.now() strictly advances + await Future.delayed(const Duration(milliseconds: 5)); + + final updated = await service.updateRoute( + route: initial.copyWith( + title: 'Updated Title', + ), + callerUid: 'alice', + ); + + expect(updated.id, equals('route_preserve_prov')); + expect(updated.ownerUid, equals('alice')); + expect(updated.title, equals('Updated Title')); + expect(updated.createdAt, equals(originalCreatedAt)); // Provenance strictly preserved! + expect(updated.updatedAt.isAfter(originalCreatedAt), isTrue); // Timestamp advances! + }); + + test('missing or empty caller identity is rejected for authority operations', () async { + final route = MotorSignalRoute( + id: 'r1', + ownerUid: 'alice', + title: 'Title', + createdAt: DateTime.now(), + updatedAt: DateTime.now(), + ); + + await expectLater( + () => service.updateRoute(route: route, callerUid: ''), + throwsA(isA().having( + (e) => e.message, + 'message', + contains('Caller identity required'), + )), + ); + + await expectLater( + () => service.deleteRoute('r1', callerUid: ''), + throwsA(isA().having( + (e) => e.message, + 'message', + contains('Caller identity required'), + )), + ); + + await expectLater( + () => service.attachRouteToGroupRide( + routeId: 'r1', + groupRideId: 'g1', + callerUid: '', + ), + throwsA(isA().having( + (e) => e.message, + 'message', + contains('Caller identity required'), + )), + ); + + await expectLater( + () => service.detachRouteFromGroupRide('g1', callerUid: ''), + throwsA(isA().having( + (e) => e.message, + 'message', + contains('Caller identity required'), + )), + ); + }); + + test('wrong route owner cannot update or delete route', () async { + final route = await service.saveRoute( + id: 'route_alice', + title: 'Alice Route', + ownerUid: 'alice', + stops: [ + const RouteStop( + id: 's1', + latitude: 37.0, + longitude: -122.0, + displayName: 'S1', + ), + ], + ); + + // Bob tries to update Alice's route -> DENIED + await expectLater( + () => service.updateRoute(route: route, callerUid: 'bob'), + throwsA(isA().having( + (e) => e.message, + 'message', + contains('Only the route owner can update'), + )), + ); + + // Bob tries to delete Alice's route -> DENIED + await expectLater( + () => service.deleteRoute('route_alice', callerUid: 'bob'), + throwsA(isA().having( + (e) => e.message, + 'message', + contains('Only the route owner can delete'), + )), + ); + }); + }); + + group('Requirement 6 & 7: Attachment Ownership Policy and Safe Detach', () { + late FakeFirestore fakeDb; + late RouteService service; + + setUp(() { + fakeDb = FakeFirestore(); + service = RouteService(firestore: fakeDb); + + // Seed group ride organized by Alice + fakeDb.groupRides['ride_alice'] = { + 'id': 'ride_alice', + 'organizerUid': 'alice', + 'title': 'Alice Group Ride', + }; + }); + + test('organizer + owned route -> ALLOW attach', () async { + final route = await service.saveRoute( + id: 'route_alice', + title: 'Alice Route', + ownerUid: 'alice', + stops: [ + const RouteStop( + id: 's1', + latitude: 37.7, + longitude: -122.4, + displayName: 'SF', + ), + ], + ); + + await service.attachRouteToGroupRide( + route: route, + groupRideId: 'ride_alice', + callerUid: 'alice', + ); + + // Verified attached in Group Ride + expect(fakeDb.groupRides['ride_alice']?['routeId'], equals('route_alice')); + expect(fakeDb.groupRides['ride_alice']?['routeTitle'], equals('Alice Route')); + + // Verified attached in Route doc with groupOnly visibility + expect(fakeDb.routes['route_alice']?['attachedGroupRideId'], equals('ride_alice')); + expect(fakeDb.routes['route_alice']?['visibility'], equals(RouteVisibility.groupOnly.name)); + }); + + test('organizer + someone else private route -> DENY attach', () async { + await service.saveRoute( + id: 'route_bob', + title: 'Bob Private Route', + ownerUid: 'bob', + stops: [ + const RouteStop( + id: 's1', + latitude: 37.7, + longitude: -122.4, + displayName: 'SF', + ), + ], + ); + + // Alice is organizer of ride_alice, but tries to attach Bob's route -> DENIED + await expectLater( + () => service.attachRouteToGroupRide( + routeId: 'route_bob', + groupRideId: 'ride_alice', + callerUid: 'alice', + ), + throwsA(isA().having( + (e) => e.message, + 'message', + contains('Organizer can only attach a route they own'), + )), + ); + }); + + test('ordinary participant + any route -> DENY attach', () async { + final route = await service.saveRoute( + id: 'route_bob', + title: 'Bob Route', + ownerUid: 'bob', + stops: [ + const RouteStop( + id: 's1', + latitude: 37.7, + longitude: -122.4, + displayName: 'SF', + ), + ], + ); + + // Bob is participant, not organizer of ride_alice -> DENIED + await expectLater( + () => service.attachRouteToGroupRide( + route: route, + groupRideId: 'ride_alice', + callerUid: 'bob', + ), + throwsA(isA().having( + (e) => e.message, + 'message', + contains('Only the Group Ride organizer can attach a route'), + )), + ); + }); + + test('safe detach: organizer authority clears group ride and restores route to private', () async { + // 1. Setup attached route + final route = await service.saveRoute( + id: 'route_alice', + title: 'Alice Route', + ownerUid: 'alice', + stops: [ + const RouteStop( + id: 's1', + latitude: 37.7, + longitude: -122.4, + displayName: 'SF', + ), + ], + ); + await service.attachRouteToGroupRide( + route: route, + groupRideId: 'ride_alice', + callerUid: 'alice', + ); + + // 2. Non-organizer Bob attempts detach -> DENIED + await expectLater( + () => service.detachRouteFromGroupRide('ride_alice', callerUid: 'bob'), + throwsA(isA().having( + (e) => e.message, + 'message', + contains('Only the organizer can detach'), + )), + ); + + // 3. Organizer Alice detaches route -> ALLOWED + await service.detachRouteFromGroupRide('ride_alice', callerUid: 'alice'); + + // Group Ride route fields deleted + expect(fakeDb.groupRides['ride_alice']?.containsKey('routeId'), isFalse); + expect(fakeDb.groupRides['ride_alice']?.containsKey('routeTitle'), isFalse); + + // Route attachedGroupRideId cleared and visibility restored to private + expect(fakeDb.routes['route_alice']?.containsKey('attachedGroupRideId'), isFalse); + expect(fakeDb.routes['route_alice']?['visibility'], equals(RouteVisibility.private.name)); + }); + }); + + group('Requirement 8: Safe Delete of Attached Route', () { + late FakeFirestore fakeDb; + late RouteService service; + + setUp(() { + fakeDb = FakeFirestore(); + service = RouteService(firestore: fakeDb); + }); + + test('cannot delete attached route if caller is not the group organizer', () async { + // Bob organizes group ride + fakeDb.groupRides['ride_bob'] = { + 'id': 'ride_bob', + 'organizerUid': 'bob', + 'title': 'Bob Ride', + 'routeId': 'route_alice', + }; + + // Alice owns route attached to Bob's group ride + fakeDb.routes['route_alice'] = { + 'id': 'route_alice', + 'ownerUid': 'alice', + 'title': 'Alice Route', + 'attachedGroupRideId': 'ride_bob', + 'visibility': 'groupOnly', + 'stops': [], + }; + + // Alice attempts to delete route without organizer authority to detach from ride_bob -> REFUSED + await expectLater( + () => service.deleteRoute('route_alice', callerUid: 'alice'), + throwsA(isA().having( + (e) => e.message, + 'message', + contains('Cannot delete a route attached to a Group Ride you do not organize'), + )), + ); + + // Route remains in database (not dangling) + expect(fakeDb.routes.containsKey('route_alice'), isTrue); + expect(fakeDb.groupRides['ride_bob']?['routeId'], equals('route_alice')); + }); + + test('atomic delete of attached route succeeds when caller is route owner and organizer', () async { + // Alice organizes group ride + fakeDb.groupRides['ride_alice'] = { + 'id': 'ride_alice', + 'organizerUid': 'alice', + 'title': 'Alice Ride', + 'routeId': 'route_alice', + 'routeTitle': 'Alice Route', + }; + + // Alice owns route attached to her own group ride + fakeDb.routes['route_alice'] = { + 'id': 'route_alice', + 'ownerUid': 'alice', + 'title': 'Alice Route', + 'attachedGroupRideId': 'ride_alice', + 'visibility': 'groupOnly', + 'stops': [], + }; + + // Alice deletes route -> atomic batch clears group ride route reference and deletes route + await service.deleteRoute('route_alice', callerUid: 'alice'); + + expect(fakeDb.routes.containsKey('route_alice'), isFalse); + expect(fakeDb.groupRides['ride_alice']?.containsKey('routeId'), isFalse); }); });