fix: harden route security and persistence
This commit is contained in:
parent
cbeccb6063
commit
ae13dd81e1
|
|
@ -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)));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
116
firestore.rules
116
firestore.rules
|
|
@ -220,7 +220,15 @@ service cloud.firestore {
|
|||
&& request.resource.data.organizerUid == request.auth.uid
|
||||
&& request.resource.data.id == groupRideId
|
||||
&& request.resource.data.keys().hasAll(['id', 'organizerUid', 'title', 'scheduledStartAt', 'departureAt', 'meetingPoint'])
|
||||
&& request.resource.data.participantCount >= 1;
|
||||
&& request.resource.data.participantCount >= 1
|
||||
&& (
|
||||
!('routeId' in request.resource.data) ||
|
||||
request.resource.data.routeId == null ||
|
||||
(
|
||||
exists(/databases/$(database)/documents/routes/$(request.resource.data.routeId)) &&
|
||||
get(/databases/$(database)/documents/routes/$(request.resource.data.routeId)).data.ownerUid == request.auth.uid
|
||||
)
|
||||
);
|
||||
function validStatusTransition() {
|
||||
let prev = resource.data.status;
|
||||
let next = request.resource.data.status;
|
||||
|
|
@ -230,9 +238,21 @@ service cloud.firestore {
|
|||
(prev == 'underway' && next in ['completed', 'canceled']);
|
||||
}
|
||||
|
||||
function validRouteAttachment() {
|
||||
return !('routeId' in request.resource.data) ||
|
||||
request.resource.data.routeId == null ||
|
||||
(
|
||||
(resource != null && 'routeId' in resource.data && request.resource.data.routeId == resource.data.routeId) ||
|
||||
(
|
||||
exists(/databases/$(database)/documents/routes/$(request.resource.data.routeId)) &&
|
||||
get(/databases/$(database)/documents/routes/$(request.resource.data.routeId)).data.ownerUid == request.auth.uid
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
allow update: if signedIn() && (
|
||||
// Organizer can update ride authority fields with valid status transitions
|
||||
(isOrganizer() && validStatusTransition()) ||
|
||||
// Organizer can update ride authority fields with valid status transitions and owned route attachment
|
||||
(isOrganizer() && validStatusTransition() && validRouteAttachment()) ||
|
||||
// Participants/invitees can only atomically adjust participantCount during join/leave transactions
|
||||
(
|
||||
canReadRide() &&
|
||||
|
|
@ -367,6 +387,11 @@ service cloud.firestore {
|
|||
return signedIn() && resource.data.ownerUid == request.auth.uid;
|
||||
}
|
||||
|
||||
function isAcceptedGroupParticipant(groupRideId, uid) {
|
||||
return exists(/databases/$(database)/documents/group_rides/$(groupRideId)/participants/$(uid)) &&
|
||||
get(/databases/$(database)/documents/group_rides/$(groupRideId)/participants/$(uid)).data.status == 'accepted';
|
||||
}
|
||||
|
||||
function canReadRoute() {
|
||||
return signedIn() && (
|
||||
isRouteOwner() ||
|
||||
|
|
@ -376,34 +401,94 @@ service cloud.firestore {
|
|||
(
|
||||
resource.data.visibility == 'groupOnly' &&
|
||||
resource.data.attachedGroupRideId != null &&
|
||||
exists(/databases/$(database)/documents/group_rides/$(resource.data.attachedGroupRideId)/participants/$(request.auth.uid))
|
||||
isAcceptedGroupParticipant(resource.data.attachedGroupRideId, request.auth.uid)
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Validates embedded stop representation.
|
||||
// Note: Client/domain validation additionally enforces unique stop IDs, sequential 0..N-1 indexing,
|
||||
// and exhaustive validation of all stops across large routes.
|
||||
// Exhaustive checking of all stops across large arrays is bounded in Firestore Rules due to expression limits.
|
||||
function validStop(stop) {
|
||||
return stop is map
|
||||
&& stop.id is string && stop.id.size() >= 1 && stop.id.size() <= 64
|
||||
&& stop.displayName is string && stop.displayName.size() >= 1 && stop.displayName.size() <= 80
|
||||
&& (stop.latitude is int || stop.latitude is float) && stop.latitude >= -90.0 && stop.latitude <= 90.0
|
||||
&& (stop.longitude is int || stop.longitude is float) && stop.longitude >= -180.0 && stop.longitude <= 180.0
|
||||
&& (
|
||||
('stopType' in stop && stop.stopType in ['start', 'origin', 'meetup', 'waypoint', 'fuel', 'rest', 'food', 'scenic', 'regroup', 'destination', 'other']) ||
|
||||
('type' in stop && stop.type in ['start', 'origin', 'meetup', 'waypoint', 'fuel', 'rest', 'food', 'scenic', 'regroup', 'destination', 'other'])
|
||||
);
|
||||
}
|
||||
|
||||
function validStops(stops) {
|
||||
return stops is list
|
||||
&& stops.size() <= 25
|
||||
&& (stops.size() < 1 || validStop(stops[0]))
|
||||
&& (stops.size() < 2 || validStop(stops[1]))
|
||||
&& (stops.size() < 3 || validStop(stops[2]))
|
||||
&& (stops.size() < 4 || validStop(stops[3]))
|
||||
&& (stops.size() < 5 || validStop(stops[4]));
|
||||
}
|
||||
|
||||
function validRouteKeys(data) {
|
||||
return data.keys().hasAll(['id', 'ownerUid', 'title', 'stops', 'createdAt', 'updatedAt'])
|
||||
&& data.keys().hasOnly([
|
||||
'id', 'ownerUid', 'ownerHandle', 'title', 'description',
|
||||
'visibility', 'stops', 'straightLineDistanceMeters',
|
||||
'estimatedDistanceMeters', 'estimatedDurationSeconds',
|
||||
'source', 'version', 'attachedGroupRideId', 'createdAt', 'updatedAt'
|
||||
]);
|
||||
}
|
||||
|
||||
function validRouteCommon(data) {
|
||||
return validRouteKeys(data)
|
||||
&& data.title is string
|
||||
&& data.title.size() >= 3
|
||||
&& data.title.size() <= 80
|
||||
&& (!('ownerHandle' in data) || (data.ownerHandle is string && data.ownerHandle.size() <= 40))
|
||||
&& (!('description' in data) || (data.description is string && data.description.size() <= 500))
|
||||
&& (!('visibility' in data) || data.visibility in ['private', 'groupOnly', 'public'])
|
||||
&& (
|
||||
!('visibility' in data) ||
|
||||
data.visibility != 'groupOnly' ||
|
||||
(('attachedGroupRideId' in data) && data.attachedGroupRideId is string && data.attachedGroupRideId.size() > 0)
|
||||
)
|
||||
&& (
|
||||
!('attachedGroupRideId' in data) ||
|
||||
data.attachedGroupRideId == null ||
|
||||
(
|
||||
data.attachedGroupRideId is string &&
|
||||
data.attachedGroupRideId.size() > 0 &&
|
||||
data.attachedGroupRideId.size() <= 64 &&
|
||||
exists(/databases/$(database)/documents/group_rides/$(data.attachedGroupRideId)) &&
|
||||
get(/databases/$(database)/documents/group_rides/$(data.attachedGroupRideId)).data.organizerUid == request.auth.uid
|
||||
)
|
||||
)
|
||||
&& (!('straightLineDistanceMeters' in data) || ((data.straightLineDistanceMeters is int || data.straightLineDistanceMeters is float) && data.straightLineDistanceMeters >= 0))
|
||||
&& (!('estimatedDistanceMeters' in data) || data.estimatedDistanceMeters == null || ((data.estimatedDistanceMeters is int || data.estimatedDistanceMeters is float) && data.estimatedDistanceMeters >= 0))
|
||||
&& (!('estimatedDurationSeconds' in data) || data.estimatedDurationSeconds == null || (data.estimatedDurationSeconds is int && data.estimatedDurationSeconds >= 0))
|
||||
&& (!('source' in data) || data.source in ['manual', 'planner', 'gpxImport', 'groupRideConversion'])
|
||||
&& (!('version' in data) || (data.version is int && data.version >= 1))
|
||||
&& validStops(data.stops);
|
||||
}
|
||||
|
||||
function validRouteCreate() {
|
||||
return signedIn()
|
||||
&& request.resource.data.id == routeId
|
||||
&& request.resource.data.ownerUid == request.auth.uid
|
||||
&& request.resource.data.title is string
|
||||
&& request.resource.data.title.size() >= 3
|
||||
&& request.resource.data.title.size() <= 80
|
||||
&& request.resource.data.stops is list
|
||||
&& request.resource.data.stops.size() <= 25
|
||||
&& request.resource.data.keys().hasAll(['id', 'ownerUid', 'title', 'stops', 'createdAt', 'updatedAt']);
|
||||
&& validRouteCommon(request.resource.data);
|
||||
}
|
||||
|
||||
function validRouteUpdate() {
|
||||
return isRouteOwner()
|
||||
&& request.resource.data.id == resource.data.id
|
||||
&& request.resource.data.ownerUid == resource.data.ownerUid
|
||||
&& request.resource.data.title is string
|
||||
&& request.resource.data.title.size() >= 3
|
||||
&& request.resource.data.title.size() <= 80
|
||||
&& request.resource.data.stops is list
|
||||
&& request.resource.data.stops.size() <= 25;
|
||||
&& request.resource.data.createdAt == resource.data.createdAt
|
||||
&& validRouteCommon(request.resource.data);
|
||||
}
|
||||
|
||||
allow read: if canReadRoute();
|
||||
|
|
@ -417,4 +502,3 @@ service cloud.firestore {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue