fix(alerts): enforce atomic count pairing and eliminate orphan vote loophole in firestore.rules

This commit is contained in:
Developer 2026-10-10 16:35:35 -04:00
parent bb3e8fb8c4
commit 789769c20e
2 changed files with 236 additions and 27 deletions

View File

@ -6325,10 +6325,18 @@ describe('MotorCloud Rider Swarm Road Alerts Security Rules', () => {
})); }));
const voteRefBob = doc(bob, `road_alerts/${alertId}/votes/bob`); const voteRefBob = doc(bob, `road_alerts/${alertId}/votes/bob`);
await assertSucceeds(setDoc(voteRefBob, { await assertSucceeds(runTransaction(bob, async (tx) => {
voterUid: 'bob', const alertRef = doc(bob, `road_alerts/${alertId}`);
type: 'confirm', const snap = await tx.get(alertRef);
votedAt: Date.now(), tx.update(alertRef, {
confirmations: snap.data().confirmations + 1,
status: 'confirmed',
});
tx.set(voteRefBob, {
voterUid: 'bob',
type: 'confirm',
votedAt: Date.now(),
});
})); }));
// Signed-in riders can read the vote subcollection // Signed-in riders can read the vote subcollection
@ -6352,10 +6360,18 @@ describe('MotorCloud Rider Swarm Road Alerts Security Rules', () => {
})); }));
const voteRefBob = doc(bob, `road_alerts/${alertId}/votes/bob`); const voteRefBob = doc(bob, `road_alerts/${alertId}/votes/bob`);
await assertSucceeds(setDoc(voteRefBob, { await assertSucceeds(runTransaction(bob, async (tx) => {
voterUid: 'bob', const alertRef = doc(bob, `road_alerts/${alertId}`);
type: 'dismiss', const snap = await tx.get(alertRef);
votedAt: Date.now(), tx.update(alertRef, {
dismissals: snap.data().dismissals + 1,
status: 'active',
});
tx.set(voteRefBob, {
voterUid: 'bob',
type: 'dismiss',
votedAt: Date.now(),
});
})); }));
}); });
@ -6372,10 +6388,18 @@ describe('MotorCloud Rider Swarm Road Alerts Security Rules', () => {
})); }));
const voteRefBob = doc(bob, `road_alerts/${alertId}/votes/bob`); const voteRefBob = doc(bob, `road_alerts/${alertId}/votes/bob`);
await assertSucceeds(setDoc(voteRefBob, { await assertSucceeds(runTransaction(bob, async (tx) => {
voterUid: 'bob', const alertRef = doc(bob, `road_alerts/${alertId}`);
type: 'confirm', const snap = await tx.get(alertRef);
votedAt: Date.now(), tx.update(alertRef, {
confirmations: snap.data().confirmations + 1,
status: 'confirmed',
});
tx.set(voteRefBob, {
voterUid: 'bob',
type: 'confirm',
votedAt: Date.now(),
});
})); }));
// Trying to overwrite via setDoc fails (update blocked) // Trying to overwrite via setDoc fails (update blocked)
@ -6389,6 +6413,21 @@ describe('MotorCloud Rider Swarm Road Alerts Security Rules', () => {
await assertFails(updateDoc(voteRefBob, { await assertFails(updateDoc(voteRefBob, {
votedAt: Date.now(), votedAt: Date.now(),
})); }));
// Second transaction also fails
await assertFails(runTransaction(bob, async (tx) => {
const alertRef = doc(bob, `road_alerts/${alertId}`);
const snap = await tx.get(alertRef);
tx.update(alertRef, {
confirmations: snap.data().confirmations + 1,
status: 'confirmed',
});
tx.set(voteRefBob, {
voterUid: 'bob',
type: 'confirm',
votedAt: Date.now(),
});
}));
}); });
test('confirmation followed by dismissal from same user (Bob tries to change vote) fails', async () => { test('confirmation followed by dismissal from same user (Bob tries to change vote) fails', async () => {
@ -6404,10 +6443,18 @@ describe('MotorCloud Rider Swarm Road Alerts Security Rules', () => {
})); }));
const voteRefBob = doc(bob, `road_alerts/${alertId}/votes/bob`); const voteRefBob = doc(bob, `road_alerts/${alertId}/votes/bob`);
await assertSucceeds(setDoc(voteRefBob, { await assertSucceeds(runTransaction(bob, async (tx) => {
voterUid: 'bob', const alertRef = doc(bob, `road_alerts/${alertId}`);
type: 'confirm', const snap = await tx.get(alertRef);
votedAt: Date.now(), tx.update(alertRef, {
confirmations: snap.data().confirmations + 1,
status: 'confirmed',
});
tx.set(voteRefBob, {
voterUid: 'bob',
type: 'confirm',
votedAt: Date.now(),
});
})); }));
// Trying to change vote to dismiss via updateDoc fails // Trying to change vote to dismiss via updateDoc fails
@ -6425,6 +6472,21 @@ describe('MotorCloud Rider Swarm Road Alerts Security Rules', () => {
// Trying to retract/delete vote fails // Trying to retract/delete vote fails
await assertFails(deleteDoc(voteRefBob)); await assertFails(deleteDoc(voteRefBob));
// Trying to change vote via transaction fails
await assertFails(runTransaction(bob, async (tx) => {
const alertRef = doc(bob, `road_alerts/${alertId}`);
const snap = await tx.get(alertRef);
tx.update(alertRef, {
dismissals: snap.data().dismissals + 1,
status: 'active',
});
tx.set(voteRefBob, {
voterUid: 'bob',
type: 'dismiss',
votedAt: Date.now(),
});
}));
}); });
test('voting on one\'s own report (Alice tries to vote on road_alerts/alert_by_alice/votes/alice) fails', async () => { test('voting on one\'s own report (Alice tries to vote on road_alerts/alert_by_alice/votes/alice) fails', async () => {
@ -6583,16 +6645,32 @@ describe('MotorCloud Rider Swarm Road Alerts Security Rules', () => {
const voteRefBob = doc(bob, `road_alerts/${alertId}/votes/bob`); const voteRefBob = doc(bob, `road_alerts/${alertId}/votes/bob`);
const voteRefCharlie = doc(charlie, `road_alerts/${alertId}/votes/charlie`); const voteRefCharlie = doc(charlie, `road_alerts/${alertId}/votes/charlie`);
await assertSucceeds(setDoc(voteRefBob, { await assertSucceeds(runTransaction(bob, async (tx) => {
voterUid: 'bob', const alertRef = doc(bob, `road_alerts/${alertId}`);
type: 'confirm', const snap = await tx.get(alertRef);
votedAt: Date.now(), tx.update(alertRef, {
confirmations: snap.data().confirmations + 1,
status: 'confirmed',
});
tx.set(voteRefBob, {
voterUid: 'bob',
type: 'confirm',
votedAt: Date.now(),
});
})); }));
await assertSucceeds(setDoc(voteRefCharlie, { await assertSucceeds(runTransaction(charlie, async (tx) => {
voterUid: 'charlie', const alertRef = doc(charlie, `road_alerts/${alertId}`);
type: 'dismiss', const snap = await tx.get(alertRef);
votedAt: Date.now(), tx.update(alertRef, {
dismissals: snap.data().dismissals + 1,
status: 'confirmed',
});
tx.set(voteRefCharlie, {
voterUid: 'charlie',
type: 'dismiss',
votedAt: Date.now(),
});
})); }));
}); });
@ -7126,5 +7204,133 @@ describe('MotorCloud Rider Swarm Road Alerts Security Rules', () => {
dismissals: 1, dismissals: 1,
})); }));
}); });
test('standalone vote creation without a parent update fails', async () => {
const alice = testEnv.authenticatedContext('alice').firestore();
const bob = testEnv.authenticatedContext('bob').firestore();
const alertId = 'alert_standalone_vote_fails';
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 voteRefBob = doc(bob, `road_alerts/${alertId}/votes/bob`);
// Standalone confirmation vote without parent update fails
await assertFails(setDoc(voteRefBob, {
voterUid: 'bob',
type: 'confirm',
votedAt: Date.now(),
}));
// Standalone dismissal vote without parent update fails
await assertFails(setDoc(voteRefBob, {
voterUid: 'bob',
type: 'dismiss',
votedAt: Date.now(),
}));
});
test('vote creation with unchanged totals fails', async () => {
const alice = testEnv.authenticatedContext('alice').firestore();
const bob = testEnv.authenticatedContext('bob').firestore();
const alertId = 'alert_unchanged_totals_fails';
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,
}));
// Transaction attempts to create vote while leaving parent counts unchanged
await assertFails(runTransaction(bob, async (tx) => {
const alertRef = doc(bob, `road_alerts/${alertId}`);
const snap = await tx.get(alertRef);
tx.update(alertRef, {
confirmations: snap.data().confirmations,
dismissals: snap.data().dismissals,
status: 'active',
});
tx.set(doc(bob, `road_alerts/${alertId}/votes/bob`), {
voterUid: 'bob',
type: 'confirm',
votedAt: Date.now(),
});
}));
});
test('confirmation vote paired with a dismissal increment fails', async () => {
const alice = testEnv.authenticatedContext('alice').firestore();
const bob = testEnv.authenticatedContext('bob').firestore();
const alertId = 'alert_mismatched_conf_dism_fails';
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,
}));
// Confirmation vote paired with dismissals + 1
await assertFails(runTransaction(bob, async (tx) => {
const alertRef = doc(bob, `road_alerts/${alertId}`);
const snap = await tx.get(alertRef);
tx.update(alertRef, {
dismissals: snap.data().dismissals + 1,
confirmations: snap.data().confirmations,
status: 'active',
});
tx.set(doc(bob, `road_alerts/${alertId}/votes/bob`), {
voterUid: 'bob',
type: 'confirm',
votedAt: Date.now(),
});
}));
});
test('dismissal vote paired with a confirmation increment fails', async () => {
const alice = testEnv.authenticatedContext('alice').firestore();
const bob = testEnv.authenticatedContext('bob').firestore();
const alertId = 'alert_mismatched_dism_conf_fails';
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,
}));
// Dismissal vote paired with confirmations + 1
await assertFails(runTransaction(bob, async (tx) => {
const alertRef = doc(bob, `road_alerts/${alertId}`);
const snap = await tx.get(alertRef);
tx.update(alertRef, {
confirmations: snap.data().confirmations + 1,
dismissals: snap.data().dismissals,
status: 'confirmed',
});
tx.set(doc(bob, `road_alerts/${alertId}/votes/bob`), {
voterUid: 'bob',
type: 'dismiss',
votedAt: Date.now(),
});
}));
});
}); });
}); });

View File

@ -1607,9 +1607,12 @@ service cloud.firestore {
&& 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 == 'confirm'
|| (request.resource.data.type == 'dismiss' && alertAfter.data.dismissals == alertDoc.data.dismissals + 1) && alertAfter.data.confirmations == alertDoc.data.confirmations + 1
|| (alertAfter.data.confirmations == alertDoc.data.confirmations && alertAfter.data.dismissals == alertDoc.data.dismissals) && alertAfter.data.dismissals == alertDoc.data.dismissals)
|| (request.resource.data.type == 'dismiss'
&& alertAfter.data.dismissals == alertDoc.data.dismissals + 1
&& alertAfter.data.confirmations == alertDoc.data.confirmations)
); );
} }