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', riderHandle: 'bob',
riderDisplayName: '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(); const alice = testEnv.authenticatedContext('alice').firestore();
@ -1192,6 +1201,308 @@ describe('Social Architecture Security Rules', () => {
routeStopCount: null, 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.organizerUid == request.auth.uid
&& request.resource.data.id == groupRideId && request.resource.data.id == groupRideId
&& request.resource.data.keys().hasAll(['id', 'organizerUid', 'title', 'scheduledStartAt', 'departureAt', 'meetingPoint']) && 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() { function validStatusTransition() {
let prev = resource.data.status; let prev = resource.data.status;
let next = request.resource.data.status; let next = request.resource.data.status;
@ -230,9 +238,21 @@ service cloud.firestore {
(prev == 'underway' && next in ['completed', 'canceled']); (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() && ( allow update: if signedIn() && (
// Organizer can update ride authority fields with valid status transitions // Organizer can update ride authority fields with valid status transitions and owned route attachment
(isOrganizer() && validStatusTransition()) || (isOrganizer() && validStatusTransition() && validRouteAttachment()) ||
// Participants/invitees can only atomically adjust participantCount during join/leave transactions // Participants/invitees can only atomically adjust participantCount during join/leave transactions
( (
canReadRide() && canReadRide() &&
@ -367,6 +387,11 @@ service cloud.firestore {
return signedIn() && resource.data.ownerUid == request.auth.uid; 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() { function canReadRoute() {
return signedIn() && ( return signedIn() && (
isRouteOwner() || isRouteOwner() ||
@ -376,34 +401,94 @@ service cloud.firestore {
( (
resource.data.visibility == 'groupOnly' && resource.data.visibility == 'groupOnly' &&
resource.data.attachedGroupRideId != null && 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() { function validRouteCreate() {
return signedIn() return signedIn()
&& request.resource.data.id == routeId && request.resource.data.id == routeId
&& request.resource.data.ownerUid == request.auth.uid && request.resource.data.ownerUid == request.auth.uid
&& request.resource.data.title is string && validRouteCommon(request.resource.data);
&& 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']);
} }
function validRouteUpdate() { function validRouteUpdate() {
return isRouteOwner() return isRouteOwner()
&& request.resource.data.id == resource.data.id && request.resource.data.id == resource.data.id
&& request.resource.data.ownerUid == resource.data.ownerUid && request.resource.data.ownerUid == resource.data.ownerUid
&& request.resource.data.title is string && request.resource.data.createdAt == resource.data.createdAt
&& request.resource.data.title.size() >= 3 && validRouteCommon(request.resource.data);
&& request.resource.data.title.size() <= 80
&& request.resource.data.stops is list
&& request.resource.data.stops.size() <= 25;
} }
allow read: if canReadRoute(); allow read: if canReadRoute();
@ -417,4 +502,3 @@ service cloud.firestore {
} }
} }
} }

View File

@ -1024,9 +1024,16 @@ class _GroupRideDetailScreenState extends ConsumerState<GroupRideDetailScreen> {
OutlinedButton.icon( OutlinedButton.icon(
onPressed: () async { onPressed: () async {
try { try {
final user = ref.read(currentUserProvider);
if (user == null || user.uid.isEmpty) {
throw StateError('Sign-in required to detach route.');
}
await ref await ref
.read(routeServiceProvider) .read(routeServiceProvider)
.detachRouteFromGroupRide(ride.id); .detachRouteFromGroupRide(
ride.id,
callerUid: user.uid,
);
if (context.mounted) { if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar( const SnackBar(
@ -1230,11 +1237,17 @@ class _GroupRideDetailScreenState extends ConsumerState<GroupRideDetailScreen> {
onTap: () async { onTap: () async {
Navigator.pop(sheetCtx); Navigator.pop(sheetCtx);
try { try {
final user = ref.read(currentUserProvider);
if (user == null || user.uid.isEmpty) {
throw StateError(
'Sign-in required to attach route.');
}
await ref await ref
.read(routeServiceProvider) .read(routeServiceProvider)
.attachRouteToGroupRide( .attachRouteToGroupRide(
groupRideId: ride.id, groupRideId: ride.id,
route: route, route: route,
callerUid: user.uid,
); );
if (context.mounted) { if (context.mounted) {
ScaffoldMessenger.of( ScaffoldMessenger.of(

View File

@ -61,8 +61,15 @@ class _RouteDetailScreenState extends ConsumerState<RouteDetailScreen> {
setState(() => _isDeleting = true); setState(() => _isDeleting = true);
try { 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); final service = ref.read(routeServiceProvider);
await service.deleteRoute(widget.routeId); await service.deleteRoute(
widget.routeId,
callerUid: user.uid,
);
if (mounted) { if (mounted) {
ScaffoldMessenger.of( ScaffoldMessenger.of(
context, context,

View File

@ -3,7 +3,6 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:geolocator/geolocator.dart'; import 'package:geolocator/geolocator.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import '../../services/auth/auth_providers.dart'; import '../../services/auth/auth_providers.dart';
import '../../services/entitlements/entitlement_providers.dart';
import '../../services/map/map_provider_interface.dart'; import '../../services/map/map_provider_interface.dart';
import '../../services/map/unified_map_view.dart'; import '../../services/map/unified_map_view.dart';
import '../../services/routes/navigation_handoff.dart'; import '../../services/routes/navigation_handoff.dart';
@ -107,27 +106,13 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
String? defaultName, String? defaultName,
}) { }) {
final state = ref.read(routePlannerNotifierProvider); final state = ref.read(routePlannerNotifierProvider);
final isPro = ref.read(isProSubscriberProvider); const maxAllowed = RouteService.maxTechnicalStops;
final maxAllowed = isPro
? RouteService.maxTechnicalStops
: RouteService.freeStopCap;
if (existingStop == null && state.stops.length >= maxAllowed) { if (existingStop == null && state.stops.length >= maxAllowed) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( const SnackBar(
content: Text( content: Text('Maximum of $maxAllowed stops reached.'),
isPro backgroundColor: Color(0xFF1B1E26),
? '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),
), ),
); );
return; return;
@ -381,17 +366,12 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
return; return;
} }
final isPro = ref.read(isProSubscriberProvider);
final state = ref.read(routePlannerNotifierProvider); final state = ref.read(routePlannerNotifierProvider);
final maxAllowed = isPro const maxAllowed = RouteService.maxTechnicalStops;
? RouteService.maxTechnicalStops
: RouteService.freeStopCap;
if (state.stops.length > maxAllowed) { if (state.stops.length > maxAllowed) {
setState(() { setState(() {
_errorMessage = isPro _errorMessage = 'Cannot exceed $maxAllowed stops.';
? 'Cannot exceed $maxAllowed stops.'
: 'Free tier limit is $maxAllowed stops. Upgrade to Pro for more.';
}); });
return; return;
} }
@ -417,6 +397,7 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
await service.attachRouteToGroupRide( await service.attachRouteToGroupRide(
groupRideId: widget.attachToGroupRideId!, groupRideId: widget.attachToGroupRideId!,
route: savedRoute, route: savedRoute,
callerUid: user.uid,
); );
} }
@ -477,10 +458,7 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
} }
final state = ref.watch(routePlannerNotifierProvider); final state = ref.watch(routePlannerNotifierProvider);
final isPro = ref.watch(isProSubscriberProvider); const maxAllowed = RouteService.maxTechnicalStops;
final maxAllowed = isPro
? RouteService.maxTechnicalStops
: RouteService.freeStopCap;
final isAtLimit = state.stops.length >= maxAllowed; final isAtLimit = state.stops.length >= maxAllowed;
// Center map on first stop or default // Center map on first stop or default
@ -807,26 +785,6 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
); );
}).toList(), }).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 'package:flutter_riverpod/flutter_riverpod.dart';
import '../auth/auth_providers.dart'; import '../auth/auth_providers.dart';
import '../entitlements/entitlement_providers.dart';
import '../map/map_provider_interface.dart'; import '../map/map_provider_interface.dart';
import 'navigation_handoff.dart'; import 'navigation_handoff.dart';
import 'route_models.dart'; import 'route_models.dart';
@ -62,6 +61,7 @@ class RoutePlannerState {
final bool isSaving; final bool isSaving;
final String? errorMessage; final String? errorMessage;
final bool isDirty; final bool isDirty;
final DateTime? createdAt;
const RoutePlannerState({ const RoutePlannerState({
this.routeId, this.routeId,
@ -73,6 +73,7 @@ class RoutePlannerState {
this.isSaving = false, this.isSaving = false,
this.errorMessage, this.errorMessage,
this.isDirty = false, this.isDirty = false,
this.createdAt,
}); });
double get distanceMiles => straightLineDistanceMeters * 0.000621371; double get distanceMiles => straightLineDistanceMeters * 0.000621371;
@ -97,6 +98,7 @@ class RoutePlannerState {
bool? isSaving, bool? isSaving,
String? errorMessage, String? errorMessage,
bool? isDirty, bool? isDirty,
DateTime? createdAt,
}) { }) {
return RoutePlannerState( return RoutePlannerState(
routeId: routeId ?? this.routeId, routeId: routeId ?? this.routeId,
@ -109,6 +111,7 @@ class RoutePlannerState {
isSaving: isSaving ?? this.isSaving, isSaving: isSaving ?? this.isSaving,
errorMessage: errorMessage, errorMessage: errorMessage,
isDirty: isDirty ?? this.isDirty, isDirty: isDirty ?? this.isDirty,
createdAt: createdAt ?? this.createdAt,
); );
} }
} }
@ -128,6 +131,7 @@ class RoutePlannerNotifier extends StateNotifier<RoutePlannerState> {
stops: initialRoute.orderedStops, stops: initialRoute.orderedStops,
straightLineDistanceMeters: straightLineDistanceMeters:
initialRoute.straightLineDistanceMeters, initialRoute.straightLineDistanceMeters,
createdAt: initialRoute.createdAt,
) )
: const RoutePlannerState(), : const RoutePlannerState(),
); );
@ -140,6 +144,7 @@ class RoutePlannerNotifier extends StateNotifier<RoutePlannerState> {
visibility: route.visibility, visibility: route.visibility,
stops: route.orderedStops, stops: route.orderedStops,
straightLineDistanceMeters: route.straightLineDistanceMeters, straightLineDistanceMeters: route.straightLineDistanceMeters,
createdAt: route.createdAt,
isDirty: false, isDirty: false,
); );
} }
@ -308,7 +313,6 @@ class RoutePlannerNotifier extends StateNotifier<RoutePlannerState> {
throw StateError('Sign-in required to save routes.'); throw StateError('Sign-in required to save routes.');
} }
final isPro = ref.read(isProSubscriberProvider);
final service = ref.read(routeServiceProvider); final service = ref.read(routeServiceProvider);
state = state.copyWith(isSaving: true, errorMessage: null); state = state.copyWith(isSaving: true, errorMessage: null);
@ -322,8 +326,8 @@ class RoutePlannerNotifier extends StateNotifier<RoutePlannerState> {
ownerHandle: user?.email?.split('@').first ?? 'rider', ownerHandle: user?.email?.split('@').first ?? 'rider',
visibility: visibility ?? state.visibility, visibility: visibility ?? state.visibility,
stops: state.stops, stops: state.stops,
isPro: isPro,
attachedGroupRideId: attachedGroupRideId, attachedGroupRideId: attachedGroupRideId,
createdAt: state.createdAt,
); );
state = state.copyWith( state = state.copyWith(

View File

@ -22,28 +22,28 @@ class RouteService {
/// Technical safety bound for maximum stops in a single route. /// Technical safety bound for maximum stops in a single route.
static const int maxTechnicalStops = 25; static const int maxTechnicalStops = 25;
/// Free tier recommended stop cap.
static const int freeStopCap = 8;
/// Validates route stops invariants. /// 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) { if (stops.length > maxTechnicalStops) {
throw ArgumentError( throw ArgumentError(
'Route cannot exceed $maxTechnicalStops stops.', '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>{}; final seenIds = <String>{};
for (int i = 0; i < stops.length; i++) { for (int i = 0; i < stops.length; i++) {
final s = stops[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.'); 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)) { if (seenIds.contains(s.id)) {
throw ArgumentError('Duplicate route stop ID: ${s.id}'); throw ArgumentError('Duplicate route stop ID: ${s.id}');
} }
@ -65,10 +65,21 @@ class RouteService {
if (s.displayName.trim().isEmpty) { if (s.displayName.trim().isEmpty) {
throw ArgumentError('Stop name cannot be empty.'); 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({ Future<MotorSignalRoute> saveRoute({
String? id, String? id,
required String title, required String title,
@ -77,17 +88,37 @@ class RouteService {
String ownerHandle = '', String ownerHandle = '',
RouteVisibility visibility = RouteVisibility.private, RouteVisibility visibility = RouteVisibility.private,
required List<RouteStop> stops, required List<RouteStop> stops,
bool isPro = false,
String? attachedGroupRideId, String? attachedGroupRideId,
DateTime? createdAt,
}) async { }) 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(); final cleanTitle = title.trim();
if (cleanTitle.length < 3 || cleanTitle.length > 80) { if (cleanTitle.length < 3 || cleanTitle.length > 80) {
throw ArgumentError('Route title must be between 3 and 80 characters.'); 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 now = DateTime.now();
final effectiveCreatedAt = createdAt ?? now;
final effectiveUpdatedAt = now;
final routeId = id ?? final routeId = id ??
'route_${now.millisecondsSinceEpoch}_${ownerUid.substring(0, ownerUid.length.clamp(0, 6))}'; 'route_${now.millisecondsSinceEpoch}_${ownerUid.substring(0, ownerUid.length.clamp(0, 6))}';
@ -114,15 +145,11 @@ class RouteService {
source: RouteSource.planner, source: RouteSource.planner,
version: 1, version: 1,
attachedGroupRideId: attachedGroupRideId, attachedGroupRideId: attachedGroupRideId,
createdAt: now, createdAt: effectiveCreatedAt,
updatedAt: now, updatedAt: effectiveUpdatedAt,
); );
final firestore = _firestore; await firestore.collection('routes').doc(routeId).set(route.toJson());
if (firestore != null) {
await firestore.collection('routes').doc(routeId).set(route.toJson());
}
return route; return route;
} }
@ -134,7 +161,6 @@ class RouteService {
String description = '', String description = '',
RouteVisibility visibility = RouteVisibility.private, RouteVisibility visibility = RouteVisibility.private,
required List<RouteStop> stops, required List<RouteStop> stops,
bool isPro = false,
String? attachedGroupRideId, String? attachedGroupRideId,
}) async { }) async {
return saveRoute( return saveRoute(
@ -144,7 +170,6 @@ class RouteService {
description: description, description: description,
visibility: visibility, visibility: visibility,
stops: stops, stops: stops,
isPro: isPro,
attachedGroupRideId: attachedGroupRideId, attachedGroupRideId: attachedGroupRideId,
); );
} }
@ -189,12 +214,14 @@ class RouteService {
}); });
} }
/// Updates an existing route. /// Updates an existing route, preserving original creation provenance.
Future<MotorSignalRoute> updateRoute({ Future<MotorSignalRoute> updateRoute({
required MotorSignalRoute route, required MotorSignalRoute route,
required String callerUid, required String callerUid,
bool isPro = false,
}) async { }) async {
if (callerUid.trim().isEmpty) {
throw StateError('Caller identity required to update route.');
}
if (route.ownerUid.isNotEmpty && route.ownerUid != callerUid) { if (route.ownerUid.isNotEmpty && route.ownerUid != callerUid) {
throw StateError('Only the route owner can update this route.'); throw StateError('Only the route owner can update this route.');
} }
@ -203,37 +230,46 @@ class RouteService {
id: route.id, id: route.id,
title: route.title, title: route.title,
description: route.description, description: route.description,
ownerUid: callerUid, ownerUid: route.ownerUid.isNotEmpty ? route.ownerUid : callerUid,
ownerHandle: route.ownerHandle, ownerHandle: route.ownerHandle,
visibility: route.visibility, visibility: route.visibility,
stops: route.stops, stops: route.stops,
isPro: isPro,
attachedGroupRideId: route.attachedGroupRideId, 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 /// Caller must be the route owner. If the route is attached to a Group Ride:
/// attachment reference without destructive cascades. /// - 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( Future<void> deleteRoute(
dynamic routeIdOrNamed, { dynamic routeIdOrNamed, {
String? routeId, String? routeId,
String? callerUid, required String callerUid,
}) async { }) async {
if (callerUid.trim().isEmpty) {
throw StateError('Caller identity required to delete route.');
}
final effectiveRouteId = final effectiveRouteId =
routeIdOrNamed is String ? routeIdOrNamed : (routeId ?? ''); routeIdOrNamed is String ? routeIdOrNamed : (routeId ?? '');
if (effectiveRouteId.isEmpty) return; if (effectiveRouteId.isEmpty) return;
final firestore = _firestore; 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 docRef = firestore.collection('routes').doc(effectiveRouteId);
final snapshot = await docRef.get(); final snapshot = await docRef.get();
if (!snapshot.exists || snapshot.data() == null) return; if (!snapshot.exists || snapshot.data() == null) return;
final data = snapshot.data()!; final data = snapshot.data()!;
if (callerUid != null && data['ownerUid'] != callerUid) { if (data['ownerUid'] != callerUid) {
throw StateError('Only the route owner can delete this route.'); throw StateError('Only the route owner can delete this route.');
} }
@ -241,14 +277,24 @@ class RouteService {
if (attachedRideId != null && attachedRideId.isNotEmpty) { if (attachedRideId != null && attachedRideId.isNotEmpty) {
final rideRef = firestore.collection('group_rides').doc(attachedRideId); final rideRef = firestore.collection('group_rides').doc(attachedRideId);
final rideSnap = await rideRef.get(); final rideSnap = await rideRef.get();
if (rideSnap.exists) { if (rideSnap.exists && rideSnap.data() != null) {
await rideRef.update({ 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(), 'routeId': FieldValue.delete(),
'routeTitle': FieldValue.delete(), 'routeTitle': FieldValue.delete(),
'routeDistanceMeters': FieldValue.delete(), 'routeDistanceMeters': FieldValue.delete(),
'routeStopCount': FieldValue.delete(), 'routeStopCount': FieldValue.delete(),
'updatedAt': DateTime.now().toIso8601String(), '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. /// 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({ Future<void> attachRouteToGroupRide({
String? routeId, String? routeId,
MotorSignalRoute? route, MotorSignalRoute? route,
required String groupRideId, required String groupRideId,
String? callerUid, required String callerUid,
}) async { }) async {
if (callerUid.trim().isEmpty) {
throw StateError('Caller identity required to attach route.');
}
final firestore = _firestore; 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; final targetRouteId = route?.id ?? routeId;
if (targetRouteId == null || targetRouteId.isEmpty) { if (targetRouteId == null || targetRouteId.isEmpty) {
@ -279,9 +333,7 @@ class RouteService {
} }
final rideData = rideSnap.data()!; final rideData = rideSnap.data()!;
if (callerUid != null && if (rideData['organizerUid'] != callerUid) {
rideData['organizerUid'] != null &&
rideData['organizerUid'] != callerUid) {
throw StateError('Only the Group Ride organizer can attach a route.'); throw StateError('Only the Group Ride organizer can attach a route.');
} }
@ -290,6 +342,10 @@ class RouteService {
throw ArgumentError('Route not found.'); 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 routeRef = firestore.collection('routes').doc(targetRouteId);
final now = DateTime.now(); final now = DateTime.now();
final batch = firestore.batch(); final batch = firestore.batch();
@ -314,21 +370,30 @@ class RouteService {
} }
/// Detaches an attached route from a Group Ride. /// 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( Future<void> detachRouteFromGroupRide(
String groupRideId, { String groupRideId, {
String? callerUid, required String callerUid,
}) async { }) async {
if (callerUid.trim().isEmpty) {
throw StateError('Caller identity required to detach route.');
}
final firestore = _firestore; 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 rideRef = firestore.collection('group_rides').doc(groupRideId);
final rideSnap = await rideRef.get(); final rideSnap = await rideRef.get();
if (!rideSnap.exists || rideSnap.data() == null) return; if (!rideSnap.exists || rideSnap.data() == null) return;
final rideData = rideSnap.data()!; final rideData = rideSnap.data()!;
if (callerUid != null && if (rideData['organizerUid'] != callerUid) {
rideData['organizerUid'] != null &&
rideData['organizerUid'] != callerUid) {
throw StateError('Only the organizer can detach a route.'); throw StateError('Only the organizer can detach a route.');
} }
@ -350,6 +415,7 @@ class RouteService {
if (routeSnap.exists) { if (routeSnap.exists) {
batch.update(routeRef, { batch.update(routeRef, {
'attachedGroupRideId': FieldValue.delete(), 'attachedGroupRideId': FieldValue.delete(),
'visibility': RouteVisibility.private.name,
'updatedAt': now.toIso8601String(), 'updatedAt': now.toIso8601String(),
}); });
} }

View File

@ -79,7 +79,7 @@ void main() {
// Verify stop is added in list // Verify stop is added in list
expect(find.text('Alice\'s Restaurant'), findsOneWidget); 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 { 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:flutter_test/flutter_test.dart';
import 'package:motorcycle_dash/services/routes/navigation_handoff.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_models.dart';
import 'package:motorcycle_dash/services/routes/route_provider.dart'; import 'package:motorcycle_dash/services/routes/route_provider.dart';
import 'package:motorcycle_dash/services/routes/route_service.dart'; import 'package:motorcycle_dash/services/routes/route_service.dart';
void main() { class FakeFirestore extends Fake implements FirebaseFirestore {
group('RouteService validation tests', () { final Map<String, Map<String, dynamic>> routes = {};
final service = RouteService(firestore: null); 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( expect(
() => service.saveRoute( () => service.saveRoute(
title: 'Hi', 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; final longTitle = 'A' * 81;
expect( expect(
() => service.saveRoute( () => service.saveRoute(
title: longTitle, 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( final stops = List.generate(
26, 26,
(i) => RouteStop( (i) => RouteStop(
@ -53,11 +186,7 @@ void main() {
); );
expect( expect(
() => service.saveRoute( () => RouteService.validateStops(stops),
title: 'Too Many Stops Route',
ownerUid: 'alice',
stops: stops,
),
throwsA(isA<ArgumentError>().having( throwsA(isA<ArgumentError>().having(
(e) => e.message, (e) => e.message,
'message', 'message',
@ -66,7 +195,7 @@ void main() {
); );
}); });
test('validates stop coordinates bounds', () async { test('validates stop coordinates bounds', () {
final invalidLatStop = [ final invalidLatStop = [
const RouteStop( const RouteStop(
id: 'stop-bad-lat', id: 'stop-bad-lat',
@ -77,13 +206,8 @@ void main() {
orderIndex: 0, orderIndex: 0,
), ),
]; ];
expect( expect(
() => service.saveRoute( () => RouteService.validateStops(invalidLatStop),
title: 'Invalid Coordinates Route',
ownerUid: 'alice',
stops: invalidLatStop,
),
throwsA(isA<ArgumentError>().having( throwsA(isA<ArgumentError>().having(
(e) => e.message, (e) => e.message,
'message', 'message',
@ -101,13 +225,8 @@ void main() {
orderIndex: 0, orderIndex: 0,
), ),
]; ];
expect( expect(
() => service.saveRoute( () => RouteService.validateStops(invalidLngStop),
title: 'Invalid Coordinates Route',
ownerUid: 'alice',
stops: invalidLngStop,
),
throwsA(isA<ArgumentError>().having( throwsA(isA<ArgumentError>().having(
(e) => e.message, (e) => e.message,
'message', 'message',
@ -116,41 +235,572 @@ void main() {
); );
}); });
test('offline fallback: saveRoute generates valid route object and reindexes stops', () async { test('validates stop ID non-empty and bounded length', () {
final inputStops = [ expect(
const RouteStop( () => RouteService.validateStops([
id: 'stop-b', const RouteStop(
latitude: 37.4, id: '',
longitude: -122.2, latitude: 37.0,
displayName: 'Midpoint', longitude: -122.0,
stopType: RouteStopType.waypoint, displayName: 'Valid',
orderIndex: 99, stopType: RouteStopType.origin,
), ),
const RouteStop( ]),
id: 'stop-a', throwsA(isA<ArgumentError>().having(
latitude: 37.7, (e) => e.message,
longitude: -122.4, 'message',
displayName: 'Start', contains('missing an ID'),
stopType: RouteStopType.origin, )),
orderIndex: 0,
),
];
final saved = await service.saveRoute(
title: 'Skyline Ridge',
description: 'Twisty Sunday Run',
ownerUid: 'alice',
visibility: RouteVisibility.groupOnly,
stops: inputStops,
); );
expect(saved.id, startsWith('route_')); expect(
expect(saved.title, equals('Skyline Ridge')); () => RouteService.validateStops([
expect(saved.ownerUid, equals('alice')); RouteStop(
expect(saved.visibility, equals(RouteVisibility.groupOnly)); id: 'x' * 65,
expect(saved.stops.length, equals(2)); latitude: 37.0,
expect(saved.stops[0].orderIndex, equals(0)); longitude: -122.0,
expect(saved.stops[1].orderIndex, equals(1)); 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: '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',
),
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<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);
}); });
}); });