feat(alerts): authoritative cross-device road alert vote aggregation via Firestore transactions
This commit is contained in:
parent
de94bbb3e3
commit
bb3e8fb8c4
|
|
@ -4,7 +4,7 @@ import {
|
||||||
assertSucceeds,
|
assertSucceeds,
|
||||||
initializeTestEnvironment,
|
initializeTestEnvironment,
|
||||||
} from '@firebase/rules-unit-testing';
|
} from '@firebase/rules-unit-testing';
|
||||||
import { collection, collectionGroup, deleteDoc, doc, getDoc, getDocs, setDoc, updateDoc, writeBatch, query, where, orderBy, limit } from 'firebase/firestore';
|
import { collection, collectionGroup, deleteDoc, doc, getDoc, getDocs, runTransaction, setDoc, updateDoc, writeBatch, query, where, orderBy, limit } from 'firebase/firestore';
|
||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
|
|
||||||
|
|
@ -6807,4 +6807,324 @@ describe('MotorCloud Rider Swarm Road Alerts Security Rules', () => {
|
||||||
source: 's'.repeat(65),
|
source: 's'.repeat(65),
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Authoritative Cross-Device Vote Aggregation via Transactions', () => {
|
||||||
|
test('votes from two devices produce consistent totals (using runTransaction)', async () => {
|
||||||
|
const alice = testEnv.authenticatedContext('alice').firestore();
|
||||||
|
const bob = testEnv.authenticatedContext('bob').firestore();
|
||||||
|
const charlie = testEnv.authenticatedContext('charlie').firestore();
|
||||||
|
const dave = testEnv.authenticatedContext('dave').firestore();
|
||||||
|
|
||||||
|
const alertId = 'alert_cross_device_totals';
|
||||||
|
await assertSucceeds(setDoc(doc(alice, `road_alerts/${alertId}`), {
|
||||||
|
...baseAlert,
|
||||||
|
id: alertId,
|
||||||
|
status: 'active',
|
||||||
|
confirmations: 1,
|
||||||
|
dismissals: 0,
|
||||||
|
reportedAt: Date.now() - 1000,
|
||||||
|
expiresAt: Date.now() + 3600000,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Bob (Device 1) runs transaction: confirms alert -> alert has confirmations: 2, status: 'confirmed'
|
||||||
|
await assertSucceeds(runTransaction(bob, async (tx) => {
|
||||||
|
const alertRef = doc(bob, `road_alerts/${alertId}`);
|
||||||
|
const snap = await tx.get(alertRef);
|
||||||
|
const nextConf = snap.data().confirmations + 1;
|
||||||
|
const nextStatus = nextConf >= 2 ? 'confirmed' : 'active';
|
||||||
|
tx.update(alertRef, {
|
||||||
|
confirmations: nextConf,
|
||||||
|
status: nextStatus,
|
||||||
|
});
|
||||||
|
tx.set(doc(bob, `road_alerts/${alertId}/votes/bob`), {
|
||||||
|
voterUid: 'bob',
|
||||||
|
type: 'confirm',
|
||||||
|
votedAt: Date.now(),
|
||||||
|
});
|
||||||
|
}));
|
||||||
|
|
||||||
|
const bobAlertSnap = await getDoc(doc(bob, `road_alerts/${alertId}`));
|
||||||
|
expect(bobAlertSnap.data().confirmations).toBe(2);
|
||||||
|
expect(bobAlertSnap.data().status).toBe('confirmed');
|
||||||
|
|
||||||
|
// Charlie (Device 2) runs transaction: confirms alert -> alert has confirmations: 3, status: 'confirmed'
|
||||||
|
await assertSucceeds(runTransaction(charlie, async (tx) => {
|
||||||
|
const alertRef = doc(charlie, `road_alerts/${alertId}`);
|
||||||
|
const snap = await tx.get(alertRef);
|
||||||
|
const nextConf = snap.data().confirmations + 1;
|
||||||
|
const nextStatus = nextConf >= 2 ? 'confirmed' : 'active';
|
||||||
|
tx.update(alertRef, {
|
||||||
|
confirmations: nextConf,
|
||||||
|
status: nextStatus,
|
||||||
|
});
|
||||||
|
tx.set(doc(charlie, `road_alerts/${alertId}/votes/charlie`), {
|
||||||
|
voterUid: 'charlie',
|
||||||
|
type: 'confirm',
|
||||||
|
votedAt: Date.now(),
|
||||||
|
});
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Dave (Device 3) reads alert -> sees confirmations: 3, status: 'confirmed'
|
||||||
|
const daveAlertSnap = await getDoc(doc(dave, `road_alerts/${alertId}`));
|
||||||
|
expect(daveAlertSnap.data().confirmations).toBe(3);
|
||||||
|
expect(daveAlertSnap.data().status).toBe('confirmed');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('repeated voting cannot increase totals', async () => {
|
||||||
|
const alice = testEnv.authenticatedContext('alice').firestore();
|
||||||
|
const bob = testEnv.authenticatedContext('bob').firestore();
|
||||||
|
|
||||||
|
const alertId = 'alert_no_repeated_votes';
|
||||||
|
await assertSucceeds(setDoc(doc(alice, `road_alerts/${alertId}`), {
|
||||||
|
...baseAlert,
|
||||||
|
id: alertId,
|
||||||
|
status: 'active',
|
||||||
|
confirmations: 1,
|
||||||
|
dismissals: 0,
|
||||||
|
reportedAt: Date.now() - 1000,
|
||||||
|
expiresAt: Date.now() + 3600000,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Bob votes once via transaction -> succeeds
|
||||||
|
await assertSucceeds(runTransaction(bob, async (tx) => {
|
||||||
|
const alertRef = doc(bob, `road_alerts/${alertId}`);
|
||||||
|
const snap = await tx.get(alertRef);
|
||||||
|
const nextConf = snap.data().confirmations + 1;
|
||||||
|
tx.update(alertRef, {
|
||||||
|
confirmations: nextConf,
|
||||||
|
status: nextConf >= 2 ? 'confirmed' : 'active',
|
||||||
|
});
|
||||||
|
tx.set(doc(bob, `road_alerts/${alertId}/votes/bob`), {
|
||||||
|
voterUid: 'bob',
|
||||||
|
type: 'confirm',
|
||||||
|
votedAt: Date.now(),
|
||||||
|
});
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Bob attempts a second transaction on the same alert -> runTransaction fails with PERMISSION_DENIED
|
||||||
|
await assertFails(runTransaction(bob, async (tx) => {
|
||||||
|
const alertRef = doc(bob, `road_alerts/${alertId}`);
|
||||||
|
const snap = await tx.get(alertRef);
|
||||||
|
const nextConf = snap.data().confirmations + 1;
|
||||||
|
tx.update(alertRef, {
|
||||||
|
confirmations: nextConf,
|
||||||
|
status: 'confirmed',
|
||||||
|
});
|
||||||
|
tx.set(doc(bob, `road_alerts/${alertId}/votes/bob`), {
|
||||||
|
voterUid: 'bob',
|
||||||
|
type: 'confirm',
|
||||||
|
votedAt: Date.now(),
|
||||||
|
});
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Alert remains at confirmations: 2
|
||||||
|
const snapAfter = await getDoc(doc(alice, `road_alerts/${alertId}`));
|
||||||
|
expect(snapAfter.data().confirmations).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('concurrent votes are counted correctly', async () => {
|
||||||
|
const alice = testEnv.authenticatedContext('alice').firestore();
|
||||||
|
const bob = testEnv.authenticatedContext('bob').firestore();
|
||||||
|
const charlie = testEnv.authenticatedContext('charlie').firestore();
|
||||||
|
|
||||||
|
const alertId = 'alert_concurrent_tx_votes';
|
||||||
|
await assertSucceeds(setDoc(doc(alice, `road_alerts/${alertId}`), {
|
||||||
|
...baseAlert,
|
||||||
|
id: alertId,
|
||||||
|
status: 'active',
|
||||||
|
confirmations: 1,
|
||||||
|
dismissals: 0,
|
||||||
|
reportedAt: Date.now() - 1000,
|
||||||
|
expiresAt: Date.now() + 3600000,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const runTx = async (client, uid) => {
|
||||||
|
for (let attempt = 0; attempt < 5; attempt++) {
|
||||||
|
try {
|
||||||
|
await runTransaction(client, async (tx) => {
|
||||||
|
const alertRef = doc(client, `road_alerts/${alertId}`);
|
||||||
|
const snap = await tx.get(alertRef);
|
||||||
|
const nextConf = snap.data().confirmations + 1;
|
||||||
|
tx.update(alertRef, {
|
||||||
|
confirmations: nextConf,
|
||||||
|
status: nextConf >= 2 ? 'confirmed' : 'active',
|
||||||
|
});
|
||||||
|
tx.set(doc(client, `road_alerts/${alertId}/votes/${uid}`), {
|
||||||
|
voterUid: uid,
|
||||||
|
type: 'confirm',
|
||||||
|
votedAt: Date.now(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
} catch (e) {
|
||||||
|
if (attempt === 4) throw e;
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Bob and Charlie submit concurrent transactions via Promise.all
|
||||||
|
await assertSucceeds(Promise.all([
|
||||||
|
runTx(bob, 'bob'),
|
||||||
|
runTx(charlie, 'charlie'),
|
||||||
|
]));
|
||||||
|
|
||||||
|
// Both succeed and alert ends with confirmations: 3
|
||||||
|
const finalAlert = await getDoc(doc(alice, `road_alerts/${alertId}`));
|
||||||
|
expect(finalAlert.data().confirmations).toBe(3);
|
||||||
|
expect(finalAlert.data().status).toBe('confirmed');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('dismissal consensus clears an alert across devices', async () => {
|
||||||
|
const alice = testEnv.authenticatedContext('alice').firestore();
|
||||||
|
const bob = testEnv.authenticatedContext('bob').firestore();
|
||||||
|
const charlie = testEnv.authenticatedContext('charlie').firestore();
|
||||||
|
const dave = testEnv.authenticatedContext('dave').firestore();
|
||||||
|
|
||||||
|
const alertId = 'alert_dismissal_consensus';
|
||||||
|
const originalExpiresAt = Date.now() + 3600000;
|
||||||
|
const reportedAt = Date.now() - 1000;
|
||||||
|
await assertSucceeds(setDoc(doc(alice, `road_alerts/${alertId}`), {
|
||||||
|
...baseAlert,
|
||||||
|
id: alertId,
|
||||||
|
status: 'active',
|
||||||
|
confirmations: 1,
|
||||||
|
dismissals: 0,
|
||||||
|
reportedAt,
|
||||||
|
expiresAt: originalExpiresAt,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Bob submits dismissal transaction -> dismissals: 1, status: 'active'
|
||||||
|
await assertSucceeds(runTransaction(bob, async (tx) => {
|
||||||
|
const alertRef = doc(bob, `road_alerts/${alertId}`);
|
||||||
|
const snap = await tx.get(alertRef);
|
||||||
|
const nextDismissals = snap.data().dismissals + 1;
|
||||||
|
tx.update(alertRef, {
|
||||||
|
dismissals: nextDismissals,
|
||||||
|
status: 'active',
|
||||||
|
});
|
||||||
|
tx.set(doc(bob, `road_alerts/${alertId}/votes/bob`), {
|
||||||
|
voterUid: 'bob',
|
||||||
|
type: 'dismiss',
|
||||||
|
votedAt: Date.now(),
|
||||||
|
});
|
||||||
|
}));
|
||||||
|
|
||||||
|
const alertAfterBob = await getDoc(doc(bob, `road_alerts/${alertId}`));
|
||||||
|
expect(alertAfterBob.data().dismissals).toBe(1);
|
||||||
|
expect(alertAfterBob.data().status).toBe('active');
|
||||||
|
|
||||||
|
// Charlie submits dismissal transaction -> dismissals: 2, status: 'cleared', expiresAt: reportedAt (or <= alert.expiresAt)
|
||||||
|
const clearTime = Date.now();
|
||||||
|
await assertSucceeds(runTransaction(charlie, async (tx) => {
|
||||||
|
const alertRef = doc(charlie, `road_alerts/${alertId}`);
|
||||||
|
const snap = await tx.get(alertRef);
|
||||||
|
const nextDismissals = snap.data().dismissals + 1;
|
||||||
|
tx.update(alertRef, {
|
||||||
|
dismissals: nextDismissals,
|
||||||
|
status: 'cleared',
|
||||||
|
expiresAt: clearTime,
|
||||||
|
});
|
||||||
|
tx.set(doc(charlie, `road_alerts/${alertId}/votes/charlie`), {
|
||||||
|
voterUid: 'charlie',
|
||||||
|
type: 'dismiss',
|
||||||
|
votedAt: clearTime,
|
||||||
|
});
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Dave reads alert -> sees status: 'cleared' and dismissals: 2
|
||||||
|
const daveAlert = await getDoc(doc(dave, `road_alerts/${alertId}`));
|
||||||
|
expect(daveAlert.data().dismissals).toBe(2);
|
||||||
|
expect(daveAlert.data().status).toBe('cleared');
|
||||||
|
expect(daveAlert.data().expiresAt).toBe(clearTime);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('expired alerts cannot be revived by a modified client', async () => {
|
||||||
|
const alice = testEnv.authenticatedContext('alice').firestore();
|
||||||
|
const bob = testEnv.authenticatedContext('bob').firestore();
|
||||||
|
|
||||||
|
const alertIdExpired = 'alert_revive_expired';
|
||||||
|
await assertSucceeds(setDoc(doc(alice, `road_alerts/${alertIdExpired}`), {
|
||||||
|
...baseAlert,
|
||||||
|
id: alertIdExpired,
|
||||||
|
status: 'active',
|
||||||
|
reportedAt: Date.now() - 5000,
|
||||||
|
expiresAt: Date.now() - 1000,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Modified client tries to update expiresAt on expired alert -> fails
|
||||||
|
await assertFails(updateDoc(doc(bob, `road_alerts/${alertIdExpired}`), {
|
||||||
|
expiresAt: Date.now() + 100000,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Modified client tries transaction vote on expired alert -> fails
|
||||||
|
await assertFails(runTransaction(bob, async (tx) => {
|
||||||
|
const alertRef = doc(bob, `road_alerts/${alertIdExpired}`);
|
||||||
|
const snap = await tx.get(alertRef);
|
||||||
|
tx.update(alertRef, {
|
||||||
|
confirmations: snap.data().confirmations + 1,
|
||||||
|
status: 'confirmed',
|
||||||
|
});
|
||||||
|
tx.set(doc(bob, `road_alerts/${alertIdExpired}/votes/bob`), {
|
||||||
|
voterUid: 'bob',
|
||||||
|
type: 'confirm',
|
||||||
|
votedAt: Date.now(),
|
||||||
|
});
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Modified client tries to vote or update a cleared alert -> fails
|
||||||
|
const alertIdCleared = 'alert_revive_cleared';
|
||||||
|
await assertSucceeds(setDoc(doc(alice, `road_alerts/${alertIdCleared}`), {
|
||||||
|
...baseAlert,
|
||||||
|
id: alertIdCleared,
|
||||||
|
status: 'cleared',
|
||||||
|
reportedAt: Date.now() - 1000,
|
||||||
|
expiresAt: Date.now() + 3600000,
|
||||||
|
}));
|
||||||
|
|
||||||
|
await assertFails(updateDoc(doc(bob, `road_alerts/${alertIdCleared}`), {
|
||||||
|
status: 'active',
|
||||||
|
}));
|
||||||
|
|
||||||
|
await assertFails(runTransaction(bob, async (tx) => {
|
||||||
|
const alertRef = doc(bob, `road_alerts/${alertIdCleared}`);
|
||||||
|
const snap = await tx.get(alertRef);
|
||||||
|
tx.update(alertRef, {
|
||||||
|
confirmations: snap.data().confirmations + 1,
|
||||||
|
status: 'confirmed',
|
||||||
|
});
|
||||||
|
tx.set(doc(bob, `road_alerts/${alertIdCleared}/votes/bob`), {
|
||||||
|
voterUid: 'bob',
|
||||||
|
type: 'confirm',
|
||||||
|
votedAt: Date.now(),
|
||||||
|
});
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('direct count manipulation without atomic vote creation remains rejected', async () => {
|
||||||
|
const alice = testEnv.authenticatedContext('alice').firestore();
|
||||||
|
const bob = testEnv.authenticatedContext('bob').firestore();
|
||||||
|
|
||||||
|
const alertId = 'alert_direct_count_rejected';
|
||||||
|
await assertSucceeds(setDoc(doc(alice, `road_alerts/${alertId}`), {
|
||||||
|
...baseAlert,
|
||||||
|
id: alertId,
|
||||||
|
status: 'active',
|
||||||
|
reportedAt: Date.now() - 1000,
|
||||||
|
expiresAt: Date.now() + 3600000,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const alertRefBob = doc(bob, `road_alerts/${alertId}`);
|
||||||
|
|
||||||
|
// Direct confirmation count increment without vote doc creation fails
|
||||||
|
await assertFails(updateDoc(alertRefBob, {
|
||||||
|
confirmations: 2,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Direct dismissal count increment without vote doc creation fails
|
||||||
|
await assertFails(updateDoc(alertRefBob, {
|
||||||
|
dismissals: 1,
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1537,24 +1537,80 @@ service cloud.firestore {
|
||||||
&& (!('status' in request.resource.data) || request.resource.data.status in ['active', 'confirmed', 'cleared', 'expired']);
|
&& (!('status' in request.resource.data) || request.resource.data.status in ['active', 'confirmed', 'cleared', 'expired']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function validAlertUpdate() {
|
||||||
|
let votePath = /databases/$(database)/documents/road_alerts/$(alertId)/votes/$(request.auth.uid);
|
||||||
|
let currentStatus = ('status' in resource.data) ? resource.data.status : 'active';
|
||||||
|
return signedIn()
|
||||||
|
&& validAlertKeys()
|
||||||
|
&& resource.data.expiresAt > request.time.toMillis()
|
||||||
|
&& currentStatus != 'cleared'
|
||||||
|
&& currentStatus != 'expired'
|
||||||
|
&& resource.data.reporterUid != request.auth.uid
|
||||||
|
&& request.resource.data.id == resource.data.id
|
||||||
|
&& request.resource.data.reporterUid == resource.data.reporterUid
|
||||||
|
&& request.resource.data.category == resource.data.category
|
||||||
|
&& request.resource.data.latitude == resource.data.latitude
|
||||||
|
&& request.resource.data.longitude == resource.data.longitude
|
||||||
|
&& request.resource.data.reportedAt == resource.data.reportedAt
|
||||||
|
&& (!('subtype' in resource.data) || request.resource.data.subtype == resource.data.subtype)
|
||||||
|
&& (!('source' in resource.data) || request.resource.data.source == resource.data.source)
|
||||||
|
&& (!('roadName' in resource.data) || request.resource.data.roadName == resource.data.roadName)
|
||||||
|
&& (!('description' in resource.data) || request.resource.data.description == resource.data.description)
|
||||||
|
&& (!('heading' in resource.data) || request.resource.data.heading == resource.data.heading)
|
||||||
|
&& (!('groupRideId' in resource.data) || request.resource.data.groupRideId == resource.data.groupRideId)
|
||||||
|
&& request.resource.data.diff(resource.data).affectedKeys().hasOnly(['confirmations', 'dismissals', 'expiresAt', 'status'])
|
||||||
|
&& !exists(votePath)
|
||||||
|
&& existsAfter(votePath)
|
||||||
|
&& getAfter(votePath).data.voterUid == request.auth.uid
|
||||||
|
&& (
|
||||||
|
(
|
||||||
|
getAfter(votePath).data.type == 'confirm'
|
||||||
|
&& request.resource.data.confirmations == resource.data.confirmations + 1
|
||||||
|
&& request.resource.data.dismissals == resource.data.dismissals
|
||||||
|
&& request.resource.data.expiresAt == resource.data.expiresAt
|
||||||
|
&& request.resource.data.status == (request.resource.data.confirmations >= 2 ? 'confirmed' : currentStatus)
|
||||||
|
) || (
|
||||||
|
getAfter(votePath).data.type == 'dismiss'
|
||||||
|
&& request.resource.data.dismissals == resource.data.dismissals + 1
|
||||||
|
&& request.resource.data.confirmations == resource.data.confirmations
|
||||||
|
&& (
|
||||||
|
(request.resource.data.dismissals >= 2 && request.resource.data.dismissals >= resource.data.confirmations)
|
||||||
|
? (request.resource.data.status == 'cleared'
|
||||||
|
&& request.resource.data.expiresAt <= resource.data.expiresAt
|
||||||
|
&& request.resource.data.expiresAt >= resource.data.reportedAt)
|
||||||
|
: (request.resource.data.status == currentStatus
|
||||||
|
&& request.resource.data.expiresAt == resource.data.expiresAt)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
allow read: if signedIn();
|
allow read: if signedIn();
|
||||||
allow create: if validAlertCreate();
|
allow create: if validAlertCreate();
|
||||||
allow update: if false; // Ordinary clients CANNOT directly mutate alert fields or aggregate counts!
|
allow update: if validAlertUpdate();
|
||||||
allow delete: if signedIn() && resource.data.reporterUid == request.auth.uid;
|
allow delete: if signedIn() && resource.data.reporterUid == request.auth.uid;
|
||||||
|
|
||||||
match /votes/{voterUid} {
|
match /votes/{voterUid} {
|
||||||
function validVoteCreate() {
|
function validVoteCreate() {
|
||||||
let alertDoc = get(/databases/$(database)/documents/road_alerts/$(alertId));
|
let alertDoc = get(/databases/$(database)/documents/road_alerts/$(alertId));
|
||||||
|
let alertAfter = getAfter(/databases/$(database)/documents/road_alerts/$(alertId));
|
||||||
|
let currentStatus = ('status' in alertDoc.data) ? alertDoc.data.status : 'active';
|
||||||
return signedIn()
|
return signedIn()
|
||||||
&& request.auth.uid == voterUid
|
&& request.auth.uid == voterUid
|
||||||
&& alertDoc != null
|
&& alertDoc != null
|
||||||
&& alertDoc.data.reporterUid != request.auth.uid
|
&& alertDoc.data.reporterUid != request.auth.uid
|
||||||
&& alertDoc.data.expiresAt > request.time.toMillis()
|
&& alertDoc.data.expiresAt > request.time.toMillis()
|
||||||
&& (!('status' in alertDoc.data) || alertDoc.data.status != 'cleared')
|
&& currentStatus != 'cleared'
|
||||||
|
&& currentStatus != 'expired'
|
||||||
&& request.resource.data.keys().hasOnly(['voterUid', 'type', 'votedAt'])
|
&& request.resource.data.keys().hasOnly(['voterUid', 'type', 'votedAt'])
|
||||||
&& request.resource.data.voterUid == voterUid
|
&& request.resource.data.voterUid == voterUid
|
||||||
&& request.resource.data.type in ['confirm', 'dismiss']
|
&& request.resource.data.type in ['confirm', 'dismiss']
|
||||||
&& (request.resource.data.votedAt is int || request.resource.data.votedAt is timestamp);
|
&& (request.resource.data.votedAt is int || request.resource.data.votedAt is timestamp)
|
||||||
|
&& (
|
||||||
|
(request.resource.data.type == 'confirm' && alertAfter.data.confirmations == alertDoc.data.confirmations + 1)
|
||||||
|
|| (request.resource.data.type == 'dismiss' && alertAfter.data.dismissals == alertDoc.data.dismissals + 1)
|
||||||
|
|| (alertAfter.data.confirmations == alertDoc.data.confirmations && alertAfter.data.dismissals == alertDoc.data.dismissals)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
allow read: if signedIn();
|
allow read: if signedIn();
|
||||||
|
|
|
||||||
|
|
@ -285,6 +285,8 @@ class SwarmRoadAlertService {
|
||||||
|
|
||||||
/// Trailing rider confirms an existing alert (increases confidence).
|
/// Trailing rider confirms an existing alert (increases confidence).
|
||||||
/// Enforces per-user vote restrictions (no repeat votes, no self-voting).
|
/// Enforces per-user vote restrictions (no repeat votes, no self-voting).
|
||||||
|
/// Uses a Firestore transaction to authoritatively update confirmation count
|
||||||
|
/// and vote subcollection atomically across all devices.
|
||||||
Future<bool> confirmAlert(String alertId, {String? voterUid}) async {
|
Future<bool> confirmAlert(String alertId, {String? voterUid}) async {
|
||||||
final uid = voterUid ?? _currentUid ?? 'local_rider';
|
final uid = voterUid ?? _currentUid ?? 'local_rider';
|
||||||
final cached = _localSwarmCache[alertId];
|
final cached = _localSwarmCache[alertId];
|
||||||
|
|
@ -305,28 +307,10 @@ class SwarmRoadAlertService {
|
||||||
}
|
}
|
||||||
_userVotes.add(voteKey);
|
_userVotes.add(voteKey);
|
||||||
|
|
||||||
final firestore = _firestore;
|
final previousCached = cached;
|
||||||
if (firestore != null) {
|
final now = DateTime.now();
|
||||||
try {
|
|
||||||
await firestore
|
|
||||||
.collection('road_alerts')
|
|
||||||
.doc(alertId)
|
|
||||||
.collection('votes')
|
|
||||||
.doc(uid)
|
|
||||||
.set({
|
|
||||||
'voterUid': uid,
|
|
||||||
'type': 'confirm',
|
|
||||||
'votedAt': DateTime.now().millisecondsSinceEpoch,
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
if (kDebugMode) {
|
|
||||||
debugPrint('[SwarmRoadAlertService] Firestore confirm error: $e');
|
|
||||||
}
|
|
||||||
_userVotes.remove(voteKey);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// Optimistically update local cache
|
||||||
if (cached != null) {
|
if (cached != null) {
|
||||||
final updatedConfirmations = cached.confirmations + 1;
|
final updatedConfirmations = cached.confirmations + 1;
|
||||||
final newStatus = updatedConfirmations >= 2
|
final newStatus = updatedConfirmations >= 2
|
||||||
|
|
@ -338,11 +322,74 @@ class SwarmRoadAlertService {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final firestore = _firestore;
|
||||||
|
if (firestore != null) {
|
||||||
|
try {
|
||||||
|
final alertRef = firestore.collection('road_alerts').doc(alertId);
|
||||||
|
final voteRef = alertRef.collection('votes').doc(uid);
|
||||||
|
|
||||||
|
await firestore.runTransaction((transaction) async {
|
||||||
|
final snapshot = await transaction.get(alertRef);
|
||||||
|
if (!snapshot.exists) {
|
||||||
|
throw Exception('Alert does not exist');
|
||||||
|
}
|
||||||
|
|
||||||
|
final data = snapshot.data();
|
||||||
|
if (data == null) {
|
||||||
|
throw Exception('Alert data is null');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data['reporterUid'] == uid) {
|
||||||
|
throw Exception('Self-voting is not permitted');
|
||||||
|
}
|
||||||
|
|
||||||
|
final expiresAtMillis = (data['expiresAt'] as num?)?.toInt() ?? 0;
|
||||||
|
if (expiresAtMillis <= now.millisecondsSinceEpoch) {
|
||||||
|
throw Exception('Alert has already expired');
|
||||||
|
}
|
||||||
|
|
||||||
|
final statusStr = data['status']?.toString();
|
||||||
|
if (statusStr == 'cleared' || statusStr == 'expired') {
|
||||||
|
throw Exception('Alert is already $statusStr');
|
||||||
|
}
|
||||||
|
|
||||||
|
final currentConfirmations = (data['confirmations'] as num?)?.toInt() ?? 1;
|
||||||
|
final updatedConfirmations = currentConfirmations + 1;
|
||||||
|
final newStatus = updatedConfirmations >= 2 ? 'confirmed' : (statusStr ?? 'active');
|
||||||
|
|
||||||
|
transaction.set(voteRef, {
|
||||||
|
'voterUid': uid,
|
||||||
|
'type': 'confirm',
|
||||||
|
'votedAt': now.millisecondsSinceEpoch,
|
||||||
|
});
|
||||||
|
|
||||||
|
transaction.update(alertRef, {
|
||||||
|
'confirmations': updatedConfirmations,
|
||||||
|
'status': newStatus,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
if (kDebugMode) {
|
||||||
|
debugPrint('[SwarmRoadAlertService] Firestore confirm error: $e');
|
||||||
|
}
|
||||||
|
// Rollback optimistic cache and vote record on failure
|
||||||
|
if (previousCached != null) {
|
||||||
|
_localSwarmCache[alertId] = previousCached;
|
||||||
|
} else {
|
||||||
|
_localSwarmCache.remove(alertId);
|
||||||
|
}
|
||||||
|
_userVotes.remove(voteKey);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Trailing rider reports alert is clear / no longer present.
|
/// Trailing rider reports alert is clear / no longer present.
|
||||||
/// Automatically marks alert cleared/expired when dismissals reach threshold.
|
/// Automatically marks alert cleared/expired when dismissals reach threshold.
|
||||||
|
/// Uses a Firestore transaction to authoritatively update dismissal count
|
||||||
|
/// and vote subcollection atomically across all devices.
|
||||||
Future<bool> clearAlert(String alertId, {String? voterUid}) async {
|
Future<bool> clearAlert(String alertId, {String? voterUid}) async {
|
||||||
final uid = voterUid ?? _currentUid ?? 'local_rider';
|
final uid = voterUid ?? _currentUid ?? 'local_rider';
|
||||||
final cached = _localSwarmCache[alertId];
|
final cached = _localSwarmCache[alertId];
|
||||||
|
|
@ -363,30 +410,10 @@ class SwarmRoadAlertService {
|
||||||
}
|
}
|
||||||
_userVotes.add(voteKey);
|
_userVotes.add(voteKey);
|
||||||
|
|
||||||
|
final previousCached = cached;
|
||||||
final now = DateTime.now();
|
final now = DateTime.now();
|
||||||
|
|
||||||
final firestore = _firestore;
|
// Optimistically update local cache
|
||||||
if (firestore != null) {
|
|
||||||
try {
|
|
||||||
await firestore
|
|
||||||
.collection('road_alerts')
|
|
||||||
.doc(alertId)
|
|
||||||
.collection('votes')
|
|
||||||
.doc(uid)
|
|
||||||
.set({
|
|
||||||
'voterUid': uid,
|
|
||||||
'type': 'dismiss',
|
|
||||||
'votedAt': now.millisecondsSinceEpoch,
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
if (kDebugMode) {
|
|
||||||
debugPrint('[SwarmRoadAlertService] Firestore clear error: $e');
|
|
||||||
}
|
|
||||||
_userVotes.remove(voteKey);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (cached != null) {
|
if (cached != null) {
|
||||||
final updatedDismissals = cached.dismissals + 1;
|
final updatedDismissals = cached.dismissals + 1;
|
||||||
if (updatedDismissals >= 2 && updatedDismissals >= cached.confirmations) {
|
if (updatedDismissals >= 2 && updatedDismissals >= cached.confirmations) {
|
||||||
|
|
@ -401,6 +428,73 @@ class SwarmRoadAlertService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final firestore = _firestore;
|
||||||
|
if (firestore != null) {
|
||||||
|
try {
|
||||||
|
final alertRef = firestore.collection('road_alerts').doc(alertId);
|
||||||
|
final voteRef = alertRef.collection('votes').doc(uid);
|
||||||
|
|
||||||
|
await firestore.runTransaction((transaction) async {
|
||||||
|
final snapshot = await transaction.get(alertRef);
|
||||||
|
if (!snapshot.exists) {
|
||||||
|
throw Exception('Alert does not exist');
|
||||||
|
}
|
||||||
|
|
||||||
|
final data = snapshot.data();
|
||||||
|
if (data == null) {
|
||||||
|
throw Exception('Alert data is null');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data['reporterUid'] == uid) {
|
||||||
|
throw Exception('Self-clearing is not permitted');
|
||||||
|
}
|
||||||
|
|
||||||
|
final expiresAtMillis = (data['expiresAt'] as num?)?.toInt() ?? 0;
|
||||||
|
final nowMillis = now.millisecondsSinceEpoch;
|
||||||
|
if (expiresAtMillis <= nowMillis) {
|
||||||
|
throw Exception('Alert has already expired');
|
||||||
|
}
|
||||||
|
|
||||||
|
final statusStr = data['status']?.toString();
|
||||||
|
if (statusStr == 'cleared' || statusStr == 'expired') {
|
||||||
|
throw Exception('Alert is already $statusStr');
|
||||||
|
}
|
||||||
|
|
||||||
|
final currentConfirmations = (data['confirmations'] as num?)?.toInt() ?? 1;
|
||||||
|
final currentDismissals = (data['dismissals'] as num?)?.toInt() ?? 0;
|
||||||
|
final updatedDismissals = currentDismissals + 1;
|
||||||
|
|
||||||
|
final isCleared = updatedDismissals >= 2 && updatedDismissals >= currentConfirmations;
|
||||||
|
final newStatus = isCleared ? 'cleared' : (statusStr ?? 'active');
|
||||||
|
final newExpiresAt = isCleared ? nowMillis : expiresAtMillis;
|
||||||
|
|
||||||
|
transaction.set(voteRef, {
|
||||||
|
'voterUid': uid,
|
||||||
|
'type': 'dismiss',
|
||||||
|
'votedAt': nowMillis,
|
||||||
|
});
|
||||||
|
|
||||||
|
transaction.update(alertRef, {
|
||||||
|
'dismissals': updatedDismissals,
|
||||||
|
'status': newStatus,
|
||||||
|
'expiresAt': newExpiresAt,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
if (kDebugMode) {
|
||||||
|
debugPrint('[SwarmRoadAlertService] Firestore clear error: $e');
|
||||||
|
}
|
||||||
|
// Rollback optimistic cache and vote record on failure
|
||||||
|
if (previousCached != null) {
|
||||||
|
_localSwarmCache[alertId] = previousCached;
|
||||||
|
} else {
|
||||||
|
_localSwarmCache.remove(alertId);
|
||||||
|
}
|
||||||
|
_userVotes.remove(voteKey);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -210,7 +210,7 @@ void main() {
|
||||||
expect(expiresAt.difference(reportedAt).inMinutes, 120);
|
expect(expiresAt.difference(reportedAt).inMinutes, 120);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('confirmAlert writes vote to subcollection and increments confirmations in memory', () async {
|
test('confirmAlert writes vote to subcollection and updates parent confirmations in Firestore and cache', () async {
|
||||||
final report = await service.reportAlert(
|
final report = await service.reportAlert(
|
||||||
category: RoadAlertCategory.police,
|
category: RoadAlertCategory.police,
|
||||||
latitude: 34.0,
|
latitude: 34.0,
|
||||||
|
|
@ -230,9 +230,18 @@ void main() {
|
||||||
expect(voteDoc.data()!['type'], 'confirm');
|
expect(voteDoc.data()!['type'], 'confirm');
|
||||||
expect(voteDoc.data()!['voterUid'], 'rider_trailing');
|
expect(voteDoc.data()!['voterUid'], 'rider_trailing');
|
||||||
expect(service.getCachedReport(report.id)?.confirmations, 2);
|
expect(service.getCachedReport(report.id)?.confirmations, 2);
|
||||||
|
expect(service.getCachedReport(report.id)?.status, RoadAlertStatus.confirmed);
|
||||||
|
|
||||||
|
// Verify Firestore parent doc is updated for cross-device visibility
|
||||||
|
final parentDoc = await fakeFirestore
|
||||||
|
.collection('road_alerts')
|
||||||
|
.doc(report.id)
|
||||||
|
.get();
|
||||||
|
expect(parentDoc.data()!['confirmations'], 2);
|
||||||
|
expect(parentDoc.data()!['status'], 'confirmed');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('clearAlert writes dismissal vote to subcollection and auto-expires after 2 dismissals', () async {
|
test('clearAlert writes dismissal vote to subcollection and auto-clears across Firestore and cache', () async {
|
||||||
final report = await service.reportAlert(
|
final report = await service.reportAlert(
|
||||||
category: RoadAlertCategory.hazard,
|
category: RoadAlertCategory.hazard,
|
||||||
latitude: 34.0,
|
latitude: 34.0,
|
||||||
|
|
@ -251,6 +260,9 @@ void main() {
|
||||||
expect(voteA.exists, isTrue);
|
expect(voteA.exists, isTrue);
|
||||||
expect(voteA.data()!['type'], 'dismiss');
|
expect(voteA.data()!['type'], 'dismiss');
|
||||||
expect(service.getCachedReport(report.id)?.dismissals, 1);
|
expect(service.getCachedReport(report.id)?.dismissals, 1);
|
||||||
|
final parentDoc1 = await fakeFirestore.collection('road_alerts').doc(report.id).get();
|
||||||
|
expect(parentDoc1.data()!['dismissals'], 1);
|
||||||
|
expect(parentDoc1.data()!['status'], 'active');
|
||||||
|
|
||||||
// 2nd clear: dismissals becomes 2, which is >= confirmations (1) -> expires immediately
|
// 2nd clear: dismissals becomes 2, which is >= confirmations (1) -> expires immediately
|
||||||
final res2 = await service.clearAlert(report.id, voterUid: 'rider_b');
|
final res2 = await service.clearAlert(report.id, voterUid: 'rider_b');
|
||||||
|
|
@ -265,6 +277,45 @@ void main() {
|
||||||
expect(voteB.data()!['type'], 'dismiss');
|
expect(voteB.data()!['type'], 'dismiss');
|
||||||
expect(service.getCachedReport(report.id)?.dismissals, 2);
|
expect(service.getCachedReport(report.id)?.dismissals, 2);
|
||||||
expect(service.getCachedReport(report.id)?.status, RoadAlertStatus.cleared);
|
expect(service.getCachedReport(report.id)?.status, RoadAlertStatus.cleared);
|
||||||
|
|
||||||
|
// Verify Firestore parent doc is cleared for all devices
|
||||||
|
final parentDoc2 = await fakeFirestore.collection('road_alerts').doc(report.id).get();
|
||||||
|
expect(parentDoc2.data()!['dismissals'], 2);
|
||||||
|
expect(parentDoc2.data()!['status'], 'cleared');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('failed vote rolls back optimistic cache and vote state', () async {
|
||||||
|
final past = DateTime.now().subtract(const Duration(hours: 2));
|
||||||
|
final expiredReport = SwarmReport(
|
||||||
|
id: 'swarm_expired_test',
|
||||||
|
category: RoadAlertCategory.hazard,
|
||||||
|
latitude: 34.0,
|
||||||
|
longitude: -118.0,
|
||||||
|
description: 'Old obstacle',
|
||||||
|
reporterUid: 'rider_original',
|
||||||
|
reportedAt: past,
|
||||||
|
expiresAt: past.add(const Duration(minutes: 30)),
|
||||||
|
confirmations: 1,
|
||||||
|
dismissals: 0,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Save expired alert to Firestore
|
||||||
|
await fakeFirestore
|
||||||
|
.collection('road_alerts')
|
||||||
|
.doc(expiredReport.id)
|
||||||
|
.set(expiredReport.toJson());
|
||||||
|
|
||||||
|
// Attempt to confirm expired alert
|
||||||
|
final result = await service.confirmAlert(expiredReport.id, voterUid: 'rider_other');
|
||||||
|
expect(result, isFalse);
|
||||||
|
|
||||||
|
// Verify vote record was rolled back
|
||||||
|
expect(service.hasUserVoted(expiredReport.id, 'rider_other'), isFalse);
|
||||||
|
expect(service.canUserVote(expiredReport.id, 'rider_other'), isTrue);
|
||||||
|
|
||||||
|
// Verify Firestore parent doc was untouched
|
||||||
|
final doc = await fakeFirestore.collection('road_alerts').doc(expiredReport.id).get();
|
||||||
|
expect(doc.data()!['confirmations'], 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('getAlertsNear finds alerts within radius and excludes distant ones', () async {
|
test('getAlertsNear finds alerts within radius and excludes distant ones', () async {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue