fix: harden route security and persistence

This commit is contained in:
Developer 2026-09-04 14:36:39 -04:00
parent cbeccb6063
commit ae13dd81e1
9 changed files with 1267 additions and 174 deletions

View File

@ -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)));
});
});
});

View File

@ -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 {
}
}
}

View File

@ -1024,9 +1024,16 @@ class _GroupRideDetailScreenState extends ConsumerState<GroupRideDetailScreen> {
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<GroupRideDetailScreen> {
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(

View File

@ -61,8 +61,15 @@ class _RouteDetailScreenState extends ConsumerState<RouteDetailScreen> {
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,

View File

@ -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<RoutePlannerScreen> {
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<RoutePlannerScreen> {
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<RoutePlannerScreen> {
await service.attachRouteToGroupRide(
groupRideId: widget.attachToGroupRideId!,
route: savedRoute,
callerUid: user.uid,
);
}
@ -477,10 +458,7 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
}
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<RoutePlannerScreen> {
);
}).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,
),
),
),
],
),
],

View File

@ -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<RoutePlannerState> {
stops: initialRoute.orderedStops,
straightLineDistanceMeters:
initialRoute.straightLineDistanceMeters,
createdAt: initialRoute.createdAt,
)
: const RoutePlannerState(),
);
@ -140,6 +144,7 @@ class RoutePlannerNotifier extends StateNotifier<RoutePlannerState> {
visibility: route.visibility,
stops: route.orderedStops,
straightLineDistanceMeters: route.straightLineDistanceMeters,
createdAt: route.createdAt,
isDirty: false,
);
}
@ -308,7 +313,6 @@ class RoutePlannerNotifier extends StateNotifier<RoutePlannerState> {
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<RoutePlannerState> {
ownerHandle: user?.email?.split('@').first ?? 'rider',
visibility: visibility ?? state.visibility,
stops: state.stops,
isPro: isPro,
attachedGroupRideId: attachedGroupRideId,
createdAt: state.createdAt,
);
state = state.copyWith(

View File

@ -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<RouteStop> 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<RouteStop> 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 = <String>{};
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<MotorSignalRoute> saveRoute({
String? id,
required String title,
@ -77,17 +88,37 @@ class RouteService {
String ownerHandle = '',
RouteVisibility visibility = RouteVisibility.private,
required List<RouteStop> 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());
}
return route;
}
@ -134,7 +161,6 @@ class RouteService {
String description = '',
RouteVisibility visibility = RouteVisibility.private,
required List<RouteStop> 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<MotorSignalRoute> 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<void> 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<void> 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<void> 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(),
});
}

View File

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

View File

@ -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<String, Map<String, dynamic>> routes = {};
final Map<String, Map<String, dynamic>> groupRides = {};
@override
CollectionReference<Map<String, dynamic>> collection(String collectionPath) {
return FakeCollectionReference(this, collectionPath);
}
@override
WriteBatch batch() => FakeWriteBatch(this);
}
class FakeCollectionReference extends Fake
implements CollectionReference<Map<String, dynamic>> {
@override
final FakeFirestore firestore;
@override
final String path;
FakeCollectionReference(this.firestore, this.path);
@override
DocumentReference<Map<String, dynamic>> doc([String? docId]) {
return FakeDocumentReference(firestore, path, docId ?? 'auto_id');
}
}
class FakeDocumentReference extends Fake
implements DocumentReference<Map<String, dynamic>> {
@override
final FakeFirestore firestore;
final String collectionPath;
final String docId;
FakeDocumentReference(this.firestore, this.collectionPath, this.docId);
@override
String get id => docId;
Map<String, Map<String, dynamic>> get _store =>
collectionPath == 'routes' ? firestore.routes : firestore.groupRides;
@override
Future<void> set(Map<String, dynamic> data, [SetOptions? options]) async {
_store[docId] = Map<String, dynamic>.from(data);
}
@override
Future<DocumentSnapshot<Map<String, dynamic>>> get([GetOptions? options]) async {
final data = _store[docId];
return FakeDocumentSnapshot(this, docId, data);
}
@override
Future<void> update(Map<Object, Object?> data) async {
final existing = _store[docId] ?? {};
final updated = Map<String, dynamic>.from(existing);
data.forEach((k, v) {
if (v == FieldValue.delete()) {
updated.remove(k.toString());
} else {
updated[k.toString()] = v;
}
});
_store[docId] = updated;
}
@override
Future<void> delete() async {
_store.remove(docId);
}
}
class FakeDocumentSnapshot extends Fake
implements DocumentSnapshot<Map<String, dynamic>> {
final DocumentReference<Map<String, dynamic>> _ref;
final String _id;
final Map<String, dynamic>? _data;
FakeDocumentSnapshot(this._ref, this._id, this._data);
@override
String get id => _id;
@override
bool get exists => _data != null;
@override
Map<String, dynamic>? data() =>
_data != null ? Map<String, dynamic>.from(_data) : null;
@override
DocumentReference<Map<String, dynamic>> get reference => _ref;
}
class FakeWriteBatch extends Fake implements WriteBatch {
final FakeFirestore firestore;
final List<Future<void> Function()> operations = [];
FakeWriteBatch(this.firestore);
@override
void update(DocumentReference<Object?> document, Map<Object, Object?> data) {
operations.add(() async {
final doc = document as FakeDocumentReference;
await doc.update(data);
});
}
@override
void delete(DocumentReference<Object?> document) {
operations.add(() async {
final doc = document as FakeDocumentReference;
await doc.delete();
});
}
@override
Future<void> 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<ArgumentError>().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<ArgumentError>().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<ArgumentError>().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 = [
test('validates stop ID non-empty and bounded length', () {
expect(
() => RouteService.validateStops([
const RouteStop(
id: 'stop-b',
latitude: 37.4,
longitude: -122.2,
displayName: 'Midpoint',
stopType: RouteStopType.waypoint,
orderIndex: 99,
id: '',
latitude: 37.0,
longitude: -122.0,
displayName: 'Valid',
stopType: RouteStopType.origin,
),
]),
throwsA(isA<ArgumentError>().having(
(e) => e.message,
'message',
contains('missing an ID'),
)),
);
expect(
() => RouteService.validateStops([
RouteStop(
id: 'x' * 65,
latitude: 37.0,
longitude: -122.0,
displayName: 'Valid',
stopType: RouteStopType.origin,
),
]),
throwsA(isA<ArgumentError>().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: 'stop-a',
id: 'same-id',
latitude: 37.1,
longitude: -122.1,
displayName: 'Stop 2',
),
];
expect(
() => RouteService.validateStops(duplicateStops),
throwsA(isA<ArgumentError>().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<ArgumentError>().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<ArgumentError>().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<ArgumentError>().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<StateError>().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<StateError>().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<StateError>().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<StateError>().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<StateError>().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',
stopType: RouteStopType.origin,
orderIndex: 0,
),
];
final saved = await service.saveRoute(
title: 'Skyline Ridge',
description: 'Twisty Sunday Run',
ownerUid: 'alice',
visibility: RouteVisibility.groupOnly,
stops: inputStops,
const RouteStop(
id: 's2',
latitude: 37.3,
longitude: -121.8,
displayName: 'End',
),
],
);
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(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<StateError>().having(
(e) => e.message,
'message',
contains('Caller identity required'),
)),
);
await expectLater(
() => service.deleteRoute('r1', callerUid: ''),
throwsA(isA<StateError>().having(
(e) => e.message,
'message',
contains('Caller identity required'),
)),
);
await expectLater(
() => service.attachRouteToGroupRide(
routeId: 'r1',
groupRideId: 'g1',
callerUid: '',
),
throwsA(isA<StateError>().having(
(e) => e.message,
'message',
contains('Caller identity required'),
)),
);
await expectLater(
() => service.detachRouteFromGroupRide('g1', callerUid: ''),
throwsA(isA<StateError>().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<StateError>().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<StateError>().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<StateError>().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<StateError>().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<StateError>().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<StateError>().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);
});
});