fix(alerts): harden road alert voting, isolate Waze OSINT adapter, purge OpenWebNinja
This commit is contained in:
parent
7bd5a0e585
commit
de94bbb3e3
|
|
@ -6310,211 +6310,404 @@ describe('MotorCloud Rider Swarm Road Alerts Security Rules', () => {
|
|||
}));
|
||||
});
|
||||
|
||||
test('valid voting: single confirmation increment by 1 with preserved expiresAt', async () => {
|
||||
test('first valid confirmation by Bob on Alice report succeeds and can be read by signed-in users', async () => {
|
||||
const alice = testEnv.authenticatedContext('alice').firestore();
|
||||
const bob = testEnv.authenticatedContext('bob').firestore();
|
||||
const alertRefAlice = doc(alice, 'road_alerts/alert_hazard_101');
|
||||
const alertRefBob = doc(bob, 'road_alerts/alert_hazard_101');
|
||||
|
||||
await assertSucceeds(setDoc(alertRefAlice, baseAlert));
|
||||
|
||||
// Bob confirms alert (+1 confirmation)
|
||||
await assertSucceeds(updateDoc(alertRefBob, {
|
||||
confirmations: 2,
|
||||
}));
|
||||
|
||||
// Confirmations is now 2. Another user Charlie confirms (+1 confirmation)
|
||||
const charlie = testEnv.authenticatedContext('charlie').firestore();
|
||||
const alertRefCharlie = doc(charlie, 'road_alerts/alert_hazard_101');
|
||||
await assertSucceeds(updateDoc(alertRefCharlie, {
|
||||
confirmations: 3,
|
||||
}));
|
||||
});
|
||||
|
||||
test('valid voting: single dismissal increment by 1 without expiration change', async () => {
|
||||
const alice = testEnv.authenticatedContext('alice').firestore();
|
||||
const bob = testEnv.authenticatedContext('bob').firestore();
|
||||
const alertRefAlice = doc(alice, 'road_alerts/alert_hazard_101');
|
||||
const alertRefBob = doc(bob, 'road_alerts/alert_hazard_101');
|
||||
|
||||
await assertSucceeds(setDoc(alertRefAlice, baseAlert));
|
||||
|
||||
// Bob dismisses alert (+1 dismissal, expiresAt unchanged)
|
||||
await assertSucceeds(updateDoc(alertRefBob, {
|
||||
dismissals: 1,
|
||||
}));
|
||||
});
|
||||
|
||||
test('valid voting: single dismissal increment by 1 with shortened expiration (early clear)', async () => {
|
||||
const alice = testEnv.authenticatedContext('alice').firestore();
|
||||
const bob = testEnv.authenticatedContext('bob').firestore();
|
||||
const alertRefAlice = doc(alice, 'road_alerts/alert_hazard_101');
|
||||
const alertRefBob = doc(bob, 'road_alerts/alert_hazard_101');
|
||||
|
||||
await assertSucceeds(setDoc(alertRefAlice, baseAlert));
|
||||
|
||||
// Bob reports clear, incrementing dismissals to 1 and setting expiresAt earlier (e.g. now = 1001500)
|
||||
await assertSucceeds(updateDoc(alertRefBob, {
|
||||
dismissals: 1,
|
||||
expiresAt: 1001500,
|
||||
}));
|
||||
});
|
||||
|
||||
test('valid voting: single dismissal increment setting expiresAt exactly to reportedAt', async () => {
|
||||
const alice = testEnv.authenticatedContext('alice').firestore();
|
||||
const bob = testEnv.authenticatedContext('bob').firestore();
|
||||
const alertRefAlice = doc(alice, 'road_alerts/alert_hazard_101');
|
||||
const alertRefBob = doc(bob, 'road_alerts/alert_hazard_101');
|
||||
|
||||
await assertSucceeds(setDoc(alertRefAlice, baseAlert));
|
||||
|
||||
// Setting expiresAt to reportedAt exactly is bounded (reportedAt <= expiresAt <= previous expiresAt)
|
||||
await assertSucceeds(updateDoc(alertRefBob, {
|
||||
dismissals: 1,
|
||||
expiresAt: 1000000,
|
||||
}));
|
||||
});
|
||||
|
||||
test('rejects vote inflation and multi-vote manipulation', async () => {
|
||||
const alice = testEnv.authenticatedContext('alice').firestore();
|
||||
const bob = testEnv.authenticatedContext('bob').firestore();
|
||||
const signedOut = testEnv.unauthenticatedContext().firestore();
|
||||
const alertRefAlice = doc(alice, 'road_alerts/alert_hazard_101');
|
||||
const alertRefBob = doc(bob, 'road_alerts/alert_hazard_101');
|
||||
const alertRefSignedOut = doc(signedOut, 'road_alerts/alert_hazard_101');
|
||||
|
||||
await assertSucceeds(setDoc(alertRefAlice, baseAlert));
|
||||
const alertId = 'alert_hazard_confirm_1';
|
||||
await assertSucceeds(setDoc(doc(alice, `road_alerts/${alertId}`), {
|
||||
...baseAlert,
|
||||
id: alertId,
|
||||
reportedAt: Date.now() - 1000,
|
||||
expiresAt: Date.now() + 3600000,
|
||||
}));
|
||||
|
||||
// Signed-out user cannot vote
|
||||
await assertFails(updateDoc(alertRefSignedOut, {
|
||||
const voteRefBob = doc(bob, `road_alerts/${alertId}/votes/bob`);
|
||||
await assertSucceeds(setDoc(voteRefBob, {
|
||||
voterUid: 'bob',
|
||||
type: 'confirm',
|
||||
votedAt: Date.now(),
|
||||
}));
|
||||
|
||||
// Signed-in riders can read the vote subcollection
|
||||
await assertSucceeds(getDoc(voteRefBob));
|
||||
await assertSucceeds(getDoc(doc(charlie, `road_alerts/${alertId}/votes/bob`)));
|
||||
|
||||
// Signed-out user cannot read votes
|
||||
await assertFails(getDoc(doc(signedOut, `road_alerts/${alertId}/votes/bob`)));
|
||||
});
|
||||
|
||||
test('first valid dismissal by Bob succeeds', async () => {
|
||||
const alice = testEnv.authenticatedContext('alice').firestore();
|
||||
const bob = testEnv.authenticatedContext('bob').firestore();
|
||||
|
||||
const alertId = 'alert_hazard_dismiss_1';
|
||||
await assertSucceeds(setDoc(doc(alice, `road_alerts/${alertId}`), {
|
||||
...baseAlert,
|
||||
id: alertId,
|
||||
reportedAt: Date.now() - 1000,
|
||||
expiresAt: Date.now() + 3600000,
|
||||
}));
|
||||
|
||||
const voteRefBob = doc(bob, `road_alerts/${alertId}/votes/bob`);
|
||||
await assertSucceeds(setDoc(voteRefBob, {
|
||||
voterUid: 'bob',
|
||||
type: 'dismiss',
|
||||
votedAt: Date.now(),
|
||||
}));
|
||||
});
|
||||
|
||||
test('duplicate confirmation (Bob tries to overwrite or update road_alerts/alertId/votes/bob) fails', async () => {
|
||||
const alice = testEnv.authenticatedContext('alice').firestore();
|
||||
const bob = testEnv.authenticatedContext('bob').firestore();
|
||||
|
||||
const alertId = 'alert_hazard_dup_1';
|
||||
await assertSucceeds(setDoc(doc(alice, `road_alerts/${alertId}`), {
|
||||
...baseAlert,
|
||||
id: alertId,
|
||||
reportedAt: Date.now() - 1000,
|
||||
expiresAt: Date.now() + 3600000,
|
||||
}));
|
||||
|
||||
const voteRefBob = doc(bob, `road_alerts/${alertId}/votes/bob`);
|
||||
await assertSucceeds(setDoc(voteRefBob, {
|
||||
voterUid: 'bob',
|
||||
type: 'confirm',
|
||||
votedAt: Date.now(),
|
||||
}));
|
||||
|
||||
// Trying to overwrite via setDoc fails (update blocked)
|
||||
await assertFails(setDoc(voteRefBob, {
|
||||
voterUid: 'bob',
|
||||
type: 'confirm',
|
||||
votedAt: Date.now(),
|
||||
}));
|
||||
|
||||
// Trying to update via updateDoc fails
|
||||
await assertFails(updateDoc(voteRefBob, {
|
||||
votedAt: Date.now(),
|
||||
}));
|
||||
});
|
||||
|
||||
test('confirmation followed by dismissal from same user (Bob tries to change vote) fails', async () => {
|
||||
const alice = testEnv.authenticatedContext('alice').firestore();
|
||||
const bob = testEnv.authenticatedContext('bob').firestore();
|
||||
|
||||
const alertId = 'alert_hazard_change_vote_1';
|
||||
await assertSucceeds(setDoc(doc(alice, `road_alerts/${alertId}`), {
|
||||
...baseAlert,
|
||||
id: alertId,
|
||||
reportedAt: Date.now() - 1000,
|
||||
expiresAt: Date.now() + 3600000,
|
||||
}));
|
||||
|
||||
const voteRefBob = doc(bob, `road_alerts/${alertId}/votes/bob`);
|
||||
await assertSucceeds(setDoc(voteRefBob, {
|
||||
voterUid: 'bob',
|
||||
type: 'confirm',
|
||||
votedAt: Date.now(),
|
||||
}));
|
||||
|
||||
// Trying to change vote to dismiss via updateDoc fails
|
||||
await assertFails(updateDoc(voteRefBob, {
|
||||
type: 'dismiss',
|
||||
votedAt: Date.now(),
|
||||
}));
|
||||
|
||||
// Trying to change vote to dismiss via setDoc fails
|
||||
await assertFails(setDoc(voteRefBob, {
|
||||
voterUid: 'bob',
|
||||
type: 'dismiss',
|
||||
votedAt: Date.now(),
|
||||
}));
|
||||
|
||||
// Trying to retract/delete vote fails
|
||||
await assertFails(deleteDoc(voteRefBob));
|
||||
});
|
||||
|
||||
test('voting on one\'s own report (Alice tries to vote on road_alerts/alert_by_alice/votes/alice) fails', async () => {
|
||||
const alice = testEnv.authenticatedContext('alice').firestore();
|
||||
|
||||
const alertId = 'alert_hazard_self_vote_1';
|
||||
await assertSucceeds(setDoc(doc(alice, `road_alerts/${alertId}`), {
|
||||
...baseAlert,
|
||||
id: alertId,
|
||||
reporterUid: 'alice',
|
||||
reportedAt: Date.now() - 1000,
|
||||
expiresAt: Date.now() + 3600000,
|
||||
}));
|
||||
|
||||
const voteRefAlice = doc(alice, `road_alerts/${alertId}/votes/alice`);
|
||||
await assertFails(setDoc(voteRefAlice, {
|
||||
voterUid: 'alice',
|
||||
type: 'confirm',
|
||||
votedAt: Date.now(),
|
||||
}));
|
||||
});
|
||||
|
||||
test('forged voter UID (Bob authenticated tries to write to road_alerts/alertId/votes/charlie) fails', async () => {
|
||||
const alice = testEnv.authenticatedContext('alice').firestore();
|
||||
const bob = testEnv.authenticatedContext('bob').firestore();
|
||||
|
||||
const alertId = 'alert_hazard_spoof_voter_1';
|
||||
await assertSucceeds(setDoc(doc(alice, `road_alerts/${alertId}`), {
|
||||
...baseAlert,
|
||||
id: alertId,
|
||||
reportedAt: Date.now() - 1000,
|
||||
expiresAt: Date.now() + 3600000,
|
||||
}));
|
||||
|
||||
// Bob authenticated writes to charlie's doc path with charlie voterUid
|
||||
await assertFails(setDoc(doc(bob, `road_alerts/${alertId}/votes/charlie`), {
|
||||
voterUid: 'charlie',
|
||||
type: 'confirm',
|
||||
votedAt: Date.now(),
|
||||
}));
|
||||
|
||||
// Bob authenticated writes to charlie's doc path with bob voterUid
|
||||
await assertFails(setDoc(doc(bob, `road_alerts/${alertId}/votes/charlie`), {
|
||||
voterUid: 'bob',
|
||||
type: 'confirm',
|
||||
votedAt: Date.now(),
|
||||
}));
|
||||
|
||||
// Bob authenticated writes to bob's doc path with charlie voterUid
|
||||
await assertFails(setDoc(doc(bob, `road_alerts/${alertId}/votes/bob`), {
|
||||
voterUid: 'charlie',
|
||||
type: 'confirm',
|
||||
votedAt: Date.now(),
|
||||
}));
|
||||
});
|
||||
|
||||
test('direct confirmation count manipulation on road_alerts/alertId fails', async () => {
|
||||
const alice = testEnv.authenticatedContext('alice').firestore();
|
||||
const bob = testEnv.authenticatedContext('bob').firestore();
|
||||
|
||||
const alertId = 'alert_hazard_direct_conf_1';
|
||||
const alertRefAlice = doc(alice, `road_alerts/${alertId}`);
|
||||
const alertRefBob = doc(bob, `road_alerts/${alertId}`);
|
||||
|
||||
await assertSucceeds(setDoc(alertRefAlice, {
|
||||
...baseAlert,
|
||||
id: alertId,
|
||||
reportedAt: Date.now() - 1000,
|
||||
expiresAt: Date.now() + 3600000,
|
||||
}));
|
||||
|
||||
// Bob tries updateDoc(alertRefBob, { confirmations: 2 })
|
||||
await assertFails(updateDoc(alertRefBob, {
|
||||
confirmations: 2,
|
||||
}));
|
||||
await assertFails(updateDoc(alertRefSignedOut, {
|
||||
|
||||
// Reporter Alice also cannot directly mutate confirmations
|
||||
await assertFails(updateDoc(alertRefAlice, {
|
||||
confirmations: 2,
|
||||
}));
|
||||
});
|
||||
|
||||
test('direct dismissal count manipulation on road_alerts/alertId fails', async () => {
|
||||
const alice = testEnv.authenticatedContext('alice').firestore();
|
||||
const bob = testEnv.authenticatedContext('bob').firestore();
|
||||
|
||||
const alertId = 'alert_hazard_direct_dism_1';
|
||||
const alertRefAlice = doc(alice, `road_alerts/${alertId}`);
|
||||
const alertRefBob = doc(bob, `road_alerts/${alertId}`);
|
||||
|
||||
await assertSucceeds(setDoc(alertRefAlice, {
|
||||
...baseAlert,
|
||||
id: alertId,
|
||||
reportedAt: Date.now() - 1000,
|
||||
expiresAt: Date.now() + 3600000,
|
||||
}));
|
||||
|
||||
// Bob tries updateDoc(alertRefBob, { dismissals: 1 })
|
||||
await assertFails(updateDoc(alertRefBob, {
|
||||
dismissals: 1,
|
||||
}));
|
||||
|
||||
// Attempting to inflate confirmations from 1 to 5 (> 1 increment)
|
||||
await assertFails(updateDoc(alertRefBob, {
|
||||
confirmations: 5,
|
||||
}));
|
||||
|
||||
// Attempting to inflate confirmations to 9999
|
||||
await assertFails(updateDoc(alertRefBob, {
|
||||
confirmations: 9999,
|
||||
}));
|
||||
|
||||
// Attempting to inflate dismissals from 0 to 5 (> 1 increment)
|
||||
await assertFails(updateDoc(alertRefBob, {
|
||||
dismissals: 5,
|
||||
}));
|
||||
|
||||
// Attempting to inflate dismissals to 9999
|
||||
await assertFails(updateDoc(alertRefBob, {
|
||||
dismissals: 9999,
|
||||
}));
|
||||
|
||||
// Attempting to decrement confirmations
|
||||
await assertFails(updateDoc(alertRefBob, {
|
||||
confirmations: 0,
|
||||
}));
|
||||
|
||||
// Attempting to decrement dismissals
|
||||
await assertFails(updateDoc(alertRefBob, {
|
||||
dismissals: -1,
|
||||
}));
|
||||
|
||||
// Attempting to increment BOTH confirmations and dismissals simultaneously
|
||||
await assertFails(updateDoc(alertRefBob, {
|
||||
confirmations: 2,
|
||||
// Reporter Alice also cannot directly mutate dismissals
|
||||
await assertFails(updateDoc(alertRefAlice, {
|
||||
dismissals: 1,
|
||||
}));
|
||||
});
|
||||
|
||||
test('rejects arbitrary expiresAt extension and unauthorized expiration manipulation', async () => {
|
||||
test('direct expiration extension or status update on road_alerts/alertId by ordinary client fails', async () => {
|
||||
const alice = testEnv.authenticatedContext('alice').firestore();
|
||||
const bob = testEnv.authenticatedContext('bob').firestore();
|
||||
const alertRefAlice = doc(alice, 'road_alerts/alert_hazard_101');
|
||||
const alertRefBob = doc(bob, 'road_alerts/alert_hazard_101');
|
||||
|
||||
await assertSucceeds(setDoc(alertRefAlice, baseAlert));
|
||||
const alertId = 'alert_hazard_direct_exp_status_1';
|
||||
const alertRefAlice = doc(alice, `road_alerts/${alertId}`);
|
||||
const alertRefBob = doc(bob, `road_alerts/${alertId}`);
|
||||
|
||||
// Extending expiresAt into the far future during confirmation
|
||||
await assertFails(updateDoc(alertRefBob, {
|
||||
confirmations: 2,
|
||||
expiresAt: 9999999,
|
||||
await assertSucceeds(setDoc(alertRefAlice, {
|
||||
...baseAlert,
|
||||
id: alertId,
|
||||
reportedAt: Date.now() - 1000,
|
||||
expiresAt: Date.now() + 3600000,
|
||||
}));
|
||||
|
||||
// Shortening expiresAt during confirmation (confirmation must keep expiresAt unchanged)
|
||||
// Bob tries extending expiresAt
|
||||
await assertFails(updateDoc(alertRefBob, {
|
||||
confirmations: 2,
|
||||
expiresAt: 1001000,
|
||||
expiresAt: Date.now() + 9999999,
|
||||
}));
|
||||
|
||||
// Extending expiresAt beyond current expiresAt during dismissal
|
||||
// Bob tries setting status to cleared
|
||||
await assertFails(updateDoc(alertRefBob, {
|
||||
dismissals: 1,
|
||||
expiresAt: 1005000,
|
||||
status: 'cleared',
|
||||
}));
|
||||
|
||||
// Setting expiresAt earlier than reportedAt during dismissal
|
||||
await assertFails(updateDoc(alertRefBob, {
|
||||
dismissals: 1,
|
||||
expiresAt: 999999,
|
||||
// Alice (reporter) also cannot update status or expiresAt
|
||||
await assertFails(updateDoc(alertRefAlice, {
|
||||
status: 'cleared',
|
||||
}));
|
||||
|
||||
// Manipulating expiresAt without voting (neither confirmations nor dismissals incremented)
|
||||
await assertFails(updateDoc(alertRefBob, {
|
||||
expiresAt: 1001000,
|
||||
}));
|
||||
|
||||
// Manipulating expiresAt to far future with no vote
|
||||
await assertFails(updateDoc(alertRefBob, {
|
||||
expiresAt: 9999999,
|
||||
await assertFails(updateDoc(alertRefAlice, {
|
||||
expiresAt: Date.now() + 9999999,
|
||||
}));
|
||||
});
|
||||
|
||||
test('rejects tampering with immutable alert fields during voting', async () => {
|
||||
test('concurrent and distinct voters (Bob and Charlie) can both create their respective votes', async () => {
|
||||
const alice = testEnv.authenticatedContext('alice').firestore();
|
||||
const bob = testEnv.authenticatedContext('bob').firestore();
|
||||
const alertRefAlice = doc(alice, 'road_alerts/alert_hazard_101');
|
||||
const alertRefBob = doc(bob, 'road_alerts/alert_hazard_101');
|
||||
const charlie = testEnv.authenticatedContext('charlie').firestore();
|
||||
|
||||
await assertSucceeds(setDoc(alertRefAlice, baseAlert));
|
||||
|
||||
// Attempting to change reporterUid during vote
|
||||
await assertFails(updateDoc(alertRefBob, {
|
||||
confirmations: 2,
|
||||
reporterUid: 'bob',
|
||||
const alertId = 'alert_hazard_distinct_voters_1';
|
||||
await assertSucceeds(setDoc(doc(alice, `road_alerts/${alertId}`), {
|
||||
...baseAlert,
|
||||
id: alertId,
|
||||
reportedAt: Date.now() - 1000,
|
||||
expiresAt: Date.now() + 3600000,
|
||||
}));
|
||||
|
||||
// Attempting to change category during vote
|
||||
await assertFails(updateDoc(alertRefBob, {
|
||||
confirmations: 2,
|
||||
category: 'police',
|
||||
const voteRefBob = doc(bob, `road_alerts/${alertId}/votes/bob`);
|
||||
const voteRefCharlie = doc(charlie, `road_alerts/${alertId}/votes/charlie`);
|
||||
|
||||
await assertSucceeds(setDoc(voteRefBob, {
|
||||
voterUid: 'bob',
|
||||
type: 'confirm',
|
||||
votedAt: Date.now(),
|
||||
}));
|
||||
|
||||
// Attempting to change latitude during vote
|
||||
await assertFails(updateDoc(alertRefBob, {
|
||||
confirmations: 2,
|
||||
latitude: 38.0,
|
||||
await assertSucceeds(setDoc(voteRefCharlie, {
|
||||
voterUid: 'charlie',
|
||||
type: 'dismiss',
|
||||
votedAt: Date.now(),
|
||||
}));
|
||||
});
|
||||
|
||||
test('voting on an expired report fails', async () => {
|
||||
const alice = testEnv.authenticatedContext('alice').firestore();
|
||||
const bob = testEnv.authenticatedContext('bob').firestore();
|
||||
|
||||
const alertId = 'alert_hazard_expired_1';
|
||||
await assertSucceeds(setDoc(doc(alice, `road_alerts/${alertId}`), {
|
||||
...baseAlert,
|
||||
id: alertId,
|
||||
reportedAt: Date.now() - 5000,
|
||||
expiresAt: Date.now() - 1000,
|
||||
}));
|
||||
|
||||
// Attempting to change reportedAt during vote
|
||||
await assertFails(updateDoc(alertRefBob, {
|
||||
confirmations: 2,
|
||||
reportedAt: 900000,
|
||||
const voteRefBob = doc(bob, `road_alerts/${alertId}/votes/bob`);
|
||||
await assertFails(setDoc(voteRefBob, {
|
||||
voterUid: 'bob',
|
||||
type: 'confirm',
|
||||
votedAt: Date.now(),
|
||||
}));
|
||||
});
|
||||
|
||||
test('voting on non-existent report fails', async () => {
|
||||
const bob = testEnv.authenticatedContext('bob').firestore();
|
||||
|
||||
const voteRefBob = doc(bob, 'road_alerts/alert_does_not_exist/votes/bob');
|
||||
await assertFails(setDoc(voteRefBob, {
|
||||
voterUid: 'bob',
|
||||
type: 'confirm',
|
||||
votedAt: Date.now(),
|
||||
}));
|
||||
});
|
||||
|
||||
test('voting on a cleared report fails', async () => {
|
||||
const alice = testEnv.authenticatedContext('alice').firestore();
|
||||
const bob = testEnv.authenticatedContext('bob').firestore();
|
||||
|
||||
const alertId = 'alert_hazard_cleared_1';
|
||||
await assertSucceeds(setDoc(doc(alice, `road_alerts/${alertId}`), {
|
||||
...baseAlert,
|
||||
id: alertId,
|
||||
status: 'cleared',
|
||||
reportedAt: Date.now() - 1000,
|
||||
expiresAt: Date.now() + 3600000,
|
||||
}));
|
||||
|
||||
// Attempting to inject new fields during vote
|
||||
await assertFails(updateDoc(alertRefBob, {
|
||||
confirmations: 2,
|
||||
newField: 'exploit',
|
||||
const voteRefBob = doc(bob, `road_alerts/${alertId}/votes/bob`);
|
||||
await assertFails(setDoc(voteRefBob, {
|
||||
voterUid: 'bob',
|
||||
type: 'confirm',
|
||||
votedAt: Date.now(),
|
||||
}));
|
||||
});
|
||||
|
||||
test('voting with invalid payload (e.g. unknown keys, invalid type) fails', async () => {
|
||||
const alice = testEnv.authenticatedContext('alice').firestore();
|
||||
const bob = testEnv.authenticatedContext('bob').firestore();
|
||||
|
||||
const alertId = 'alert_hazard_invalid_payload_1';
|
||||
await assertSucceeds(setDoc(doc(alice, `road_alerts/${alertId}`), {
|
||||
...baseAlert,
|
||||
id: alertId,
|
||||
reportedAt: Date.now() - 1000,
|
||||
expiresAt: Date.now() + 3600000,
|
||||
}));
|
||||
|
||||
// Attempting to modify description during vote
|
||||
await assertFails(updateDoc(alertRefBob, {
|
||||
confirmations: 2,
|
||||
description: 'Modified description',
|
||||
const voteRefBob = doc(bob, `road_alerts/${alertId}/votes/bob`);
|
||||
|
||||
// Unknown extra keys
|
||||
await assertFails(setDoc(voteRefBob, {
|
||||
voterUid: 'bob',
|
||||
type: 'confirm',
|
||||
votedAt: Date.now(),
|
||||
injectedField: true,
|
||||
}));
|
||||
|
||||
// Invalid type value
|
||||
await assertFails(setDoc(voteRefBob, {
|
||||
voterUid: 'bob',
|
||||
type: 'upvote',
|
||||
votedAt: Date.now(),
|
||||
}));
|
||||
|
||||
// Invalid votedAt type (string instead of int/timestamp)
|
||||
await assertFails(setDoc(voteRefBob, {
|
||||
voterUid: 'bob',
|
||||
type: 'confirm',
|
||||
votedAt: '2026-10-10',
|
||||
}));
|
||||
|
||||
// Missing type
|
||||
await assertFails(setDoc(voteRefBob, {
|
||||
voterUid: 'bob',
|
||||
votedAt: Date.now(),
|
||||
}));
|
||||
});
|
||||
|
||||
test('signed-out voting fails', async () => {
|
||||
const alice = testEnv.authenticatedContext('alice').firestore();
|
||||
const signedOut = testEnv.unauthenticatedContext().firestore();
|
||||
|
||||
const alertId = 'alert_hazard_unauth_vote_1';
|
||||
await assertSucceeds(setDoc(doc(alice, `road_alerts/${alertId}`), {
|
||||
...baseAlert,
|
||||
id: alertId,
|
||||
reportedAt: Date.now() - 1000,
|
||||
expiresAt: Date.now() + 3600000,
|
||||
}));
|
||||
|
||||
const voteRefSignedOut = doc(signedOut, `road_alerts/${alertId}/votes/anon`);
|
||||
await assertFails(setDoc(voteRefSignedOut, {
|
||||
voterUid: 'anon',
|
||||
type: 'confirm',
|
||||
votedAt: Date.now(),
|
||||
}));
|
||||
});
|
||||
|
||||
|
|
@ -6614,25 +6807,4 @@ describe('MotorCloud Rider Swarm Road Alerts Security Rules', () => {
|
|||
source: 's'.repeat(65),
|
||||
}));
|
||||
});
|
||||
|
||||
test('updating status to cleared alongside dismissal increment', async () => {
|
||||
const alice = testEnv.authenticatedContext('alice').firestore();
|
||||
const bob = testEnv.authenticatedContext('bob').firestore();
|
||||
const alertRefAlice = doc(alice, 'road_alerts/alert_hazard_101');
|
||||
const alertRefBob = doc(bob, 'road_alerts/alert_hazard_101');
|
||||
|
||||
await assertSucceeds(setDoc(alertRefAlice, baseAlert));
|
||||
|
||||
// Bob clears the alert: dismissal increments to 1, status becomes cleared
|
||||
await assertSucceeds(updateDoc(alertRefBob, {
|
||||
dismissals: 1,
|
||||
status: 'cleared',
|
||||
}));
|
||||
|
||||
// Bob attempts to update with invalid status: rejected
|
||||
await assertFails(updateDoc(alertRefBob, {
|
||||
dismissals: 2,
|
||||
status: 'invalid_status_enum',
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1537,35 +1537,31 @@ service cloud.firestore {
|
|||
&& (!('status' in request.resource.data) || request.resource.data.status in ['active', 'confirmed', 'cleared', 'expired']);
|
||||
}
|
||||
|
||||
function validAlertUpdate() {
|
||||
return signedIn()
|
||||
&& validAlertKeys()
|
||||
&& 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
|
||||
&& request.resource.data.diff(resource.data).affectedKeys().hasOnly(['confirmations', 'dismissals', 'expiresAt', 'status'])
|
||||
&& (!('status' in request.resource.data) || request.resource.data.status in ['active', 'confirmed', 'cleared', 'expired'])
|
||||
&& (
|
||||
(
|
||||
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.dismissals == resource.data.dismissals + 1
|
||||
&& request.resource.data.confirmations == resource.data.confirmations
|
||||
&& request.resource.data.expiresAt <= resource.data.expiresAt
|
||||
&& request.resource.data.expiresAt >= resource.data.reportedAt
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
allow read: if signedIn();
|
||||
allow create: if validAlertCreate();
|
||||
allow update: if validAlertUpdate();
|
||||
allow update: if false; // Ordinary clients CANNOT directly mutate alert fields or aggregate counts!
|
||||
allow delete: if signedIn() && resource.data.reporterUid == request.auth.uid;
|
||||
|
||||
match /votes/{voterUid} {
|
||||
function validVoteCreate() {
|
||||
let alertDoc = get(/databases/$(database)/documents/road_alerts/$(alertId));
|
||||
return signedIn()
|
||||
&& request.auth.uid == voterUid
|
||||
&& alertDoc != null
|
||||
&& alertDoc.data.reporterUid != request.auth.uid
|
||||
&& alertDoc.data.expiresAt > request.time.toMillis()
|
||||
&& (!('status' in alertDoc.data) || alertDoc.data.status != 'cleared')
|
||||
&& request.resource.data.keys().hasOnly(['voterUid', 'type', 'votedAt'])
|
||||
&& request.resource.data.voterUid == voterUid
|
||||
&& request.resource.data.type in ['confirm', 'dismiss']
|
||||
&& (request.resource.data.votedAt is int || request.resource.data.votedAt is timestamp);
|
||||
}
|
||||
|
||||
allow read: if signedIn();
|
||||
allow create: if validVoteCreate();
|
||||
allow update: if false;
|
||||
allow delete: if false;
|
||||
}
|
||||
}
|
||||
|
||||
match /{document=**} {
|
||||
|
|
|
|||
|
|
@ -30,7 +30,6 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
|||
late TextEditingController _communityKeyController;
|
||||
late TextEditingController _communityEndpointController;
|
||||
late TextEditingController _hereKeyController;
|
||||
late TextEditingController _wazeKeyController;
|
||||
bool _isTestingTomTom = false;
|
||||
bool _isTestingCommunity = false;
|
||||
bool _isTestingHere = false;
|
||||
|
|
@ -51,9 +50,6 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
|||
_hereKeyController = TextEditingController(
|
||||
text: ref.read(hereApiKeyProvider),
|
||||
);
|
||||
_wazeKeyController = TextEditingController(
|
||||
text: ref.read(wazeApiKeyProvider),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
|
|
@ -62,7 +58,6 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
|||
_communityKeyController.dispose();
|
||||
_communityEndpointController.dispose();
|
||||
_hereKeyController.dispose();
|
||||
_wazeKeyController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -88,7 +88,7 @@ class CommunityTrafficRoadAlertProvider implements RoadAlertProvider {
|
|||
return RoadAlertProviderStatus.networkError;
|
||||
}
|
||||
|
||||
// 3. If it's a RapidAPI / OpenWebNinja key format
|
||||
// 3. If it's a RapidAPI key format
|
||||
if (_isRapidApiKey(key)) {
|
||||
final uri = Uri.parse('https://google-map-places.p.rapidapi.com/maps/api/place/nearbysearch/json?location=37.7749,-122.4194&radius=500');
|
||||
final response = await http.get(uri, headers: {
|
||||
|
|
|
|||
|
|
@ -346,13 +346,10 @@ final hereStatusProvider = StateProvider<RoadAlertProviderStatus>((ref) {
|
|||
return RoadAlertProviderStatus.configured;
|
||||
});
|
||||
|
||||
// Waze Police Alerts API Key (OpenWebNinja)
|
||||
// Custom Waze Alerts API Key
|
||||
class WazeApiKeyNotifier extends StateNotifier<String> {
|
||||
static const _key = 'waze_api_key';
|
||||
static const String defaultKey = String.fromEnvironment(
|
||||
'OPENWEBNINJA_API_KEY',
|
||||
defaultValue: String.fromEnvironment('WAZE_API_KEY', defaultValue: ''),
|
||||
);
|
||||
static const String defaultKey = String.fromEnvironment('WAZE_API_KEY', defaultValue: '');
|
||||
|
||||
WazeApiKeyNotifier() : super(defaultKey) {
|
||||
_load();
|
||||
|
|
|
|||
|
|
@ -289,7 +289,7 @@ class SwarmRoadAlertService {
|
|||
final uid = voterUid ?? _currentUid ?? 'local_rider';
|
||||
final cached = _localSwarmCache[alertId];
|
||||
|
||||
if (cached != null && cached.reporterUid == uid && voterUid != null) {
|
||||
if (cached != null && cached.reporterUid == uid) {
|
||||
if (kDebugMode) {
|
||||
debugPrint('[SwarmRoadAlertService] Self-voting rejected for alert $alertId');
|
||||
}
|
||||
|
|
@ -305,6 +305,28 @@ class SwarmRoadAlertService {
|
|||
}
|
||||
_userVotes.add(voteKey);
|
||||
|
||||
final firestore = _firestore;
|
||||
if (firestore != null) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
if (cached != null) {
|
||||
final updatedConfirmations = cached.confirmations + 1;
|
||||
final newStatus = updatedConfirmations >= 2
|
||||
|
|
@ -316,18 +338,6 @@ class SwarmRoadAlertService {
|
|||
);
|
||||
}
|
||||
|
||||
final firestore = _firestore;
|
||||
if (firestore != null) {
|
||||
try {
|
||||
await firestore.collection('road_alerts').doc(alertId).update({
|
||||
'confirmations': FieldValue.increment(1),
|
||||
});
|
||||
} catch (e) {
|
||||
if (kDebugMode) {
|
||||
debugPrint('[SwarmRoadAlertService] Firestore confirm error: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
@ -335,6 +345,15 @@ class SwarmRoadAlertService {
|
|||
/// Automatically marks alert cleared/expired when dismissals reach threshold.
|
||||
Future<bool> clearAlert(String alertId, {String? voterUid}) async {
|
||||
final uid = voterUid ?? _currentUid ?? 'local_rider';
|
||||
final cached = _localSwarmCache[alertId];
|
||||
|
||||
if (cached != null && cached.reporterUid == uid) {
|
||||
if (kDebugMode) {
|
||||
debugPrint('[SwarmRoadAlertService] Self-clearing rejected for alert $alertId');
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
final voteKey = '$alertId:$uid';
|
||||
if (_userVotes.contains(voteKey)) {
|
||||
if (kDebugMode) {
|
||||
|
|
@ -344,9 +363,30 @@ class SwarmRoadAlertService {
|
|||
}
|
||||
_userVotes.add(voteKey);
|
||||
|
||||
final cached = _localSwarmCache[alertId];
|
||||
final now = DateTime.now();
|
||||
|
||||
final firestore = _firestore;
|
||||
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) {
|
||||
final updatedDismissals = cached.dismissals + 1;
|
||||
if (updatedDismissals >= 2 && updatedDismissals >= cached.confirmations) {
|
||||
|
|
@ -361,34 +401,6 @@ class SwarmRoadAlertService {
|
|||
}
|
||||
}
|
||||
|
||||
final firestore = _firestore;
|
||||
if (firestore != null) {
|
||||
try {
|
||||
final docRef = firestore.collection('road_alerts').doc(alertId);
|
||||
final doc = await docRef.get();
|
||||
if (doc.exists && doc.data() != null) {
|
||||
final data = doc.data()!;
|
||||
final dismissals = (data['dismissals'] as num?)?.toInt() ?? 0;
|
||||
final confirmations = (data['confirmations'] as num?)?.toInt() ?? 1;
|
||||
|
||||
if (dismissals + 1 >= 2 && (dismissals + 1) >= confirmations) {
|
||||
await docRef.update({
|
||||
'dismissals': FieldValue.increment(1),
|
||||
'expiresAt': now.millisecondsSinceEpoch,
|
||||
'status': 'cleared',
|
||||
});
|
||||
} else {
|
||||
await docRef.update({
|
||||
'dismissals': FieldValue.increment(1),
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (kDebugMode) {
|
||||
debugPrint('[SwarmRoadAlertService] Firestore clear error: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
@ -454,16 +466,38 @@ class SwarmRoadAlertService {
|
|||
final report = SwarmReport.fromJson(doc.data());
|
||||
// Filter out cleared/expired documents
|
||||
if (report.status == RoadAlertStatus.cleared ||
|
||||
report.status == RoadAlertStatus.expired) {
|
||||
report.status == RoadAlertStatus.expired ||
|
||||
report.isExpired) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Honor local in-memory vote states and clearance
|
||||
final cached = _localSwarmCache[report.id];
|
||||
if (cached != null &&
|
||||
(cached.status == RoadAlertStatus.cleared ||
|
||||
cached.status == RoadAlertStatus.expired ||
|
||||
cached.isExpired)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
final effectiveReport = cached != null
|
||||
? report.copyWith(
|
||||
confirmations:
|
||||
math.max(cached.confirmations, report.confirmations),
|
||||
dismissals: math.max(cached.dismissals, report.dismissals),
|
||||
status: cached.status == RoadAlertStatus.confirmed
|
||||
? RoadAlertStatus.confirmed
|
||||
: report.status,
|
||||
)
|
||||
: report;
|
||||
|
||||
// Client-side bounding box filter
|
||||
if (report.latitude >= minLat &&
|
||||
report.latitude <= maxLat &&
|
||||
report.longitude >= minLng &&
|
||||
report.longitude <= maxLng) {
|
||||
candidates[report.id] = report;
|
||||
_localSwarmCache[report.id] = report;
|
||||
if (effectiveReport.latitude >= minLat &&
|
||||
effectiveReport.latitude <= maxLat &&
|
||||
effectiveReport.longitude >= minLng &&
|
||||
effectiveReport.longitude <= maxLng) {
|
||||
candidates[effectiveReport.id] = effectiveReport;
|
||||
_localSwarmCache[effectiveReport.id] = effectiveReport;
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,15 +18,32 @@ class WazeOsintRoadAlertProvider implements RoadAlertProvider {
|
|||
final http.Client _client;
|
||||
final String baseUrl;
|
||||
final Duration minQueryInterval;
|
||||
final List<RoadAlert>? fixtureAlerts;
|
||||
final bool _allowInjectedClient;
|
||||
|
||||
DateTime? _lastQueryTime;
|
||||
List<RoadAlert> _cachedAlerts = [];
|
||||
|
||||
/// Compile-time environment flag. Default is false (fails closed).
|
||||
/// To enable network collection in debug mode for manual developer testing, pass:
|
||||
/// `--dart-define=ENABLE_EXPERIMENTAL_WAZE_OSINT=true`
|
||||
static const bool kEnableWazeOsintNetwork = bool.fromEnvironment(
|
||||
'ENABLE_EXPERIMENTAL_WAZE_OSINT',
|
||||
defaultValue: false,
|
||||
);
|
||||
|
||||
/// Evaluates whether live network access to the unofficial Waze endpoint is permitted.
|
||||
/// Fails closed in release mode (`kReleaseMode`), and requires explicit developer
|
||||
/// compile-time authorization (`ENABLE_EXPERIMENTAL_WAZE_OSINT=true`) even in debug mode.
|
||||
static bool get isLiveNetworkAllowed => kDebugMode && kEnableWazeOsintNetwork;
|
||||
|
||||
WazeOsintRoadAlertProvider({
|
||||
http.Client? client,
|
||||
this.baseUrl = 'https://www.waze.com/live-map/api/georss',
|
||||
this.minQueryInterval = const Duration(seconds: 30),
|
||||
}) : _client = client ?? http.Client();
|
||||
this.fixtureAlerts,
|
||||
}) : _client = client ?? http.Client(),
|
||||
_allowInjectedClient = client != null;
|
||||
|
||||
@override
|
||||
String get id => 'waze_osint';
|
||||
|
|
@ -48,9 +65,52 @@ class WazeOsintRoadAlertProvider implements RoadAlertProvider {
|
|||
RoadAlertCategory.weatherHazard,
|
||||
};
|
||||
|
||||
/// Determines the appropriate Waze LiveMap environment (`na`, `il`, or `row`)
|
||||
/// based on query coordinates.
|
||||
///
|
||||
/// Documented Waze LiveMap environments:
|
||||
/// - `na`: North America (USA, Canada, Mexico, Puerto Rico, US Virgin Islands)
|
||||
/// - `il`: Israel
|
||||
/// - `row`: Rest of World (Europe, Asia, South America, Oceania, Africa)
|
||||
static String determineEnvironment({
|
||||
required double latitude,
|
||||
required double longitude,
|
||||
}) {
|
||||
if (latitude.isNaN || longitude.isNaN) return 'row';
|
||||
final lat = latitude.clamp(-90.0, 90.0);
|
||||
|
||||
// Normalize longitude across antimeridian to [-180.0, 180.0]
|
||||
var lng = (longitude + 180.0) % 360.0;
|
||||
if (lng < 0) lng += 360.0;
|
||||
lng -= 180.0;
|
||||
|
||||
// Israel bounding region
|
||||
if (lat >= 29.0 && lat <= 34.0 && lng >= 34.0 && lng <= 36.5) {
|
||||
return 'il';
|
||||
}
|
||||
|
||||
// North America bounding region:
|
||||
// USA (including Alaska, Hawaii, Puerto Rico), Canada, Mexico, Caribbean
|
||||
// Lat: 10.0°N to 75.0°N, Lng: -180.0°W to -50.0°W
|
||||
// Aleutian Islands crossing the antimeridian: Lat 50.0°N to 56.0°N, Lng 170.0°E to 180.0°E
|
||||
if (lat >= 10.0 && lat <= 75.0) {
|
||||
if (lng >= -180.0 && lng <= -50.0) {
|
||||
return 'na';
|
||||
}
|
||||
if (lat >= 50.0 && lat <= 56.0 && lng >= 170.0 && lng <= 180.0) {
|
||||
return 'na';
|
||||
}
|
||||
}
|
||||
|
||||
return 'row';
|
||||
}
|
||||
|
||||
@override
|
||||
Future<RoadAlertProviderStatus> testConnection(String apiKey) async {
|
||||
// OSINT adapter is community-sourced and does not require an API key
|
||||
// If live network is not permitted by capability gate and no test client is injected:
|
||||
if (!isLiveNetworkAllowed && !_allowInjectedClient) {
|
||||
return RoadAlertProviderStatus.notConfigured;
|
||||
}
|
||||
return RoadAlertProviderStatus.connected;
|
||||
}
|
||||
|
||||
|
|
@ -61,6 +121,23 @@ class WazeOsintRoadAlertProvider implements RoadAlertProvider {
|
|||
required double radiusMeters,
|
||||
String? apiKey,
|
||||
}) async {
|
||||
// Capability gate: Live network requests are strictly forbidden in production
|
||||
// and release configurations. In debug mode, they require explicit compile-time
|
||||
// developer authorization (ENABLE_EXPERIMENTAL_WAZE_OSINT=true).
|
||||
final canPerformNetworkQuery = isLiveNetworkAllowed || _allowInjectedClient;
|
||||
if (!canPerformNetworkQuery) {
|
||||
if (kDebugMode) {
|
||||
debugPrint(
|
||||
'[WazeOsintRoadAlertProvider] Network access disabled by capability gate. '
|
||||
'Release builds and standard debug builds do not execute live Waze requests.',
|
||||
);
|
||||
}
|
||||
if (fixtureAlerts != null) {
|
||||
return _filterByRadius(fixtureAlerts!, latitude, longitude, radiusMeters);
|
||||
}
|
||||
return _filterByRadius(_cachedAlerts, latitude, longitude, radiusMeters);
|
||||
}
|
||||
|
||||
final now = DateTime.now();
|
||||
|
||||
// Respect client rate limiting interval to prevent hammering public endpoints
|
||||
|
|
@ -76,8 +153,21 @@ class WazeOsintRoadAlertProvider implements RoadAlertProvider {
|
|||
|
||||
final top = (latitude + latDelta).clamp(-90.0, 90.0);
|
||||
final bottom = (latitude - latDelta).clamp(-90.0, 90.0);
|
||||
final left = (longitude - lngDelta).clamp(-180.0, 180.0);
|
||||
final right = (longitude + lngDelta).clamp(-180.0, 180.0);
|
||||
|
||||
// Handle longitude bounds with antimeridian wrapping and clamping
|
||||
double left = longitude - lngDelta;
|
||||
double right = longitude + lngDelta;
|
||||
if (left < -180.0) left += 360.0;
|
||||
if (right > 180.0) right -= 360.0;
|
||||
if (left > right) {
|
||||
left = -180.0;
|
||||
right = 180.0;
|
||||
} else {
|
||||
left = left.clamp(-180.0, 180.0);
|
||||
right = right.clamp(-180.0, 180.0);
|
||||
}
|
||||
|
||||
final env = determineEnvironment(latitude: latitude, longitude: longitude);
|
||||
|
||||
final uri = Uri.parse(baseUrl).replace(
|
||||
queryParameters: {
|
||||
|
|
@ -85,7 +175,7 @@ class WazeOsintRoadAlertProvider implements RoadAlertProvider {
|
|||
'bottom': bottom.toStringAsFixed(6),
|
||||
'left': left.toStringAsFixed(6),
|
||||
'right': right.toStringAsFixed(6),
|
||||
'env': 'row',
|
||||
'env': env,
|
||||
'types': 'alerts',
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -4,10 +4,9 @@ import 'package:http/http.dart' as http;
|
|||
import 'road_alert_models.dart';
|
||||
import 'road_alert_provider_interface.dart';
|
||||
|
||||
/// Real-time Waze Police Alerts provider via OpenWebNinja.
|
||||
/// Optional custom Waze Police Alerts provider for user-configured REST endpoints.
|
||||
///
|
||||
/// Queries OpenWebNinja's Waze REST endpoint exclusively for police alerts
|
||||
/// (`alert_types=POLICE`, `max_jams=0`) around the rider's coordinate.
|
||||
/// Requires a user-provided custom `apiHost` or `WAZE_API_HOST` environment variable.
|
||||
class WazeRoadAlertProvider implements RoadAlertProvider {
|
||||
final http.Client _client;
|
||||
final String apiHost;
|
||||
|
|
@ -19,10 +18,7 @@ class WazeRoadAlertProvider implements RoadAlertProvider {
|
|||
apiHost = apiHost ??
|
||||
const String.fromEnvironment(
|
||||
'WAZE_API_HOST',
|
||||
defaultValue: String.fromEnvironment(
|
||||
'OPENWEBNINJA_API_HOST',
|
||||
defaultValue: 'api.openwebninja.com',
|
||||
),
|
||||
defaultValue: '',
|
||||
);
|
||||
|
||||
@override
|
||||
|
|
@ -39,7 +35,7 @@ class WazeRoadAlertProvider implements RoadAlertProvider {
|
|||
@override
|
||||
Future<RoadAlertProviderStatus> testConnection(String apiKey) async {
|
||||
final key = apiKey.trim();
|
||||
if (key.isEmpty) {
|
||||
if (key.isEmpty || apiHost.isEmpty) {
|
||||
return RoadAlertProviderStatus.notConfigured;
|
||||
}
|
||||
|
||||
|
|
@ -84,7 +80,7 @@ class WazeRoadAlertProvider implements RoadAlertProvider {
|
|||
String? apiKey,
|
||||
}) async {
|
||||
final key = apiKey?.trim();
|
||||
if (key == null || key.isEmpty) {
|
||||
if (key == null || key.isEmpty || apiHost.isEmpty) {
|
||||
return [];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -210,37 +210,61 @@ void main() {
|
|||
expect(expiresAt.difference(reportedAt).inMinutes, 120);
|
||||
});
|
||||
|
||||
test('confirmAlert increments confirmations in memory and Firestore', () async {
|
||||
test('confirmAlert writes vote to subcollection and increments confirmations in memory', () async {
|
||||
final report = await service.reportAlert(
|
||||
category: RoadAlertCategory.police,
|
||||
latitude: 34.0,
|
||||
longitude: -118.0,
|
||||
);
|
||||
|
||||
await service.confirmAlert(report.id);
|
||||
final result = await service.confirmAlert(report.id, voterUid: 'rider_trailing');
|
||||
expect(result, isTrue);
|
||||
|
||||
final doc = await fakeFirestore.collection('road_alerts').doc(report.id).get();
|
||||
expect(doc.data()!['confirmations'], 2);
|
||||
final voteDoc = await fakeFirestore
|
||||
.collection('road_alerts')
|
||||
.doc(report.id)
|
||||
.collection('votes')
|
||||
.doc('rider_trailing')
|
||||
.get();
|
||||
expect(voteDoc.exists, isTrue);
|
||||
expect(voteDoc.data()!['type'], 'confirm');
|
||||
expect(voteDoc.data()!['voterUid'], 'rider_trailing');
|
||||
expect(service.getCachedReport(report.id)?.confirmations, 2);
|
||||
});
|
||||
|
||||
test('clearAlert increments dismissals and auto-expires after 2 dismissals', () async {
|
||||
test('clearAlert writes dismissal vote to subcollection and auto-expires after 2 dismissals', () async {
|
||||
final report = await service.reportAlert(
|
||||
category: RoadAlertCategory.hazard,
|
||||
latitude: 34.0,
|
||||
longitude: -118.0,
|
||||
);
|
||||
|
||||
// 1st clear: increments dismissals to 1
|
||||
await service.clearAlert(report.id, voterUid: 'rider_a');
|
||||
var doc = await fakeFirestore.collection('road_alerts').doc(report.id).get();
|
||||
expect(doc.data()!['dismissals'], 1);
|
||||
// 1st clear: increments dismissals to 1 and stores vote in subcollection
|
||||
final res1 = await service.clearAlert(report.id, voterUid: 'rider_a');
|
||||
expect(res1, isTrue);
|
||||
final voteA = await fakeFirestore
|
||||
.collection('road_alerts')
|
||||
.doc(report.id)
|
||||
.collection('votes')
|
||||
.doc('rider_a')
|
||||
.get();
|
||||
expect(voteA.exists, isTrue);
|
||||
expect(voteA.data()!['type'], 'dismiss');
|
||||
expect(service.getCachedReport(report.id)?.dismissals, 1);
|
||||
|
||||
// 2nd clear: dismissals becomes 2, which is >= confirmations (1) -> expires immediately
|
||||
await service.clearAlert(report.id, voterUid: 'rider_b');
|
||||
doc = await fakeFirestore.collection('road_alerts').doc(report.id).get();
|
||||
expect(doc.data()!['dismissals'], 2);
|
||||
final expiresAt = doc.data()!['expiresAt'] as int;
|
||||
expect(DateTime.now().millisecondsSinceEpoch >= expiresAt - 1000, isTrue);
|
||||
final res2 = await service.clearAlert(report.id, voterUid: 'rider_b');
|
||||
expect(res2, isTrue);
|
||||
final voteB = await fakeFirestore
|
||||
.collection('road_alerts')
|
||||
.doc(report.id)
|
||||
.collection('votes')
|
||||
.doc('rider_b')
|
||||
.get();
|
||||
expect(voteB.exists, isTrue);
|
||||
expect(voteB.data()!['type'], 'dismiss');
|
||||
expect(service.getCachedReport(report.id)?.dismissals, 2);
|
||||
expect(service.getCachedReport(report.id)?.status, RoadAlertStatus.cleared);
|
||||
});
|
||||
|
||||
test('getAlertsNear finds alerts within radius and excludes distant ones', () async {
|
||||
|
|
@ -496,9 +520,16 @@ void main() {
|
|||
expect(duplicateMerged.id, first.id);
|
||||
expect(duplicateMerged.confirmations, 2);
|
||||
|
||||
// Check Firestore doc has 2 confirmations
|
||||
final doc = await fakeFirestore.collection('road_alerts').doc(first.id).get();
|
||||
expect(doc.data()!['confirmations'], 2);
|
||||
// Check vote subcollection has confirmation vote from rider_2
|
||||
final voteDoc = await fakeFirestore
|
||||
.collection('road_alerts')
|
||||
.doc(first.id)
|
||||
.collection('votes')
|
||||
.doc('rider_2')
|
||||
.get();
|
||||
expect(voteDoc.exists, isTrue);
|
||||
expect(voteDoc.data()!['type'], 'confirm');
|
||||
expect(service.getCachedReport(first.id)?.confirmations, 2);
|
||||
|
||||
// Distant report (500m away: lat 35.0050) creates a separate alert
|
||||
final separate = await service.reportAlert(
|
||||
|
|
|
|||
|
|
@ -219,10 +219,157 @@ void main() {
|
|||
expect(alerts2.length, 1);
|
||||
});
|
||||
|
||||
test('testConnection returns connected without requiring paid API key', () async {
|
||||
final provider = WazeOsintRoadAlertProvider();
|
||||
test('testConnection with injected client returns connected', () async {
|
||||
final mockClient = MockClient((request) async {
|
||||
return http.Response(json.encode({'alerts': []}), 200);
|
||||
});
|
||||
final provider = WazeOsintRoadAlertProvider(client: mockClient);
|
||||
final status = await provider.testConnection('');
|
||||
expect(status, RoadAlertProviderStatus.connected);
|
||||
});
|
||||
});
|
||||
|
||||
group('WazeOsintRoadAlertProvider Environment / Region Selection', () {
|
||||
test('resolves North America (na) for US, Canada, Mexico, Puerto Rico, Alaska, Hawaii', () {
|
||||
// Deals Gap, NC (US-129)
|
||||
expect(
|
||||
WazeOsintRoadAlertProvider.determineEnvironment(latitude: 35.3934, longitude: -83.9261),
|
||||
'na',
|
||||
);
|
||||
// Los Angeles, CA
|
||||
expect(
|
||||
WazeOsintRoadAlertProvider.determineEnvironment(latitude: 34.0522, longitude: -118.2437),
|
||||
'na',
|
||||
);
|
||||
// Toronto, ON, Canada
|
||||
expect(
|
||||
WazeOsintRoadAlertProvider.determineEnvironment(latitude: 43.6532, longitude: -79.3832),
|
||||
'na',
|
||||
);
|
||||
// Mexico City, Mexico
|
||||
expect(
|
||||
WazeOsintRoadAlertProvider.determineEnvironment(latitude: 19.4326, longitude: -99.1332),
|
||||
'na',
|
||||
);
|
||||
// Honolulu, HI
|
||||
expect(
|
||||
WazeOsintRoadAlertProvider.determineEnvironment(latitude: 21.3069, longitude: -157.8583),
|
||||
'na',
|
||||
);
|
||||
// Anchorage, AK
|
||||
expect(
|
||||
WazeOsintRoadAlertProvider.determineEnvironment(latitude: 61.2181, longitude: -149.9003),
|
||||
'na',
|
||||
);
|
||||
// San Juan, Puerto Rico
|
||||
expect(
|
||||
WazeOsintRoadAlertProvider.determineEnvironment(latitude: 18.4655, longitude: -66.1057),
|
||||
'na',
|
||||
);
|
||||
});
|
||||
|
||||
test('resolves Israel (il) for Israeli coordinates', () {
|
||||
// Tel Aviv
|
||||
expect(
|
||||
WazeOsintRoadAlertProvider.determineEnvironment(latitude: 32.0853, longitude: 34.7818),
|
||||
'il',
|
||||
);
|
||||
// Jerusalem
|
||||
expect(
|
||||
WazeOsintRoadAlertProvider.determineEnvironment(latitude: 31.7683, longitude: 35.2137),
|
||||
'il',
|
||||
);
|
||||
// Haifa
|
||||
expect(
|
||||
WazeOsintRoadAlertProvider.determineEnvironment(latitude: 32.7940, longitude: 34.9896),
|
||||
'il',
|
||||
);
|
||||
});
|
||||
|
||||
test('resolves Rest of World (row) for Europe, Asia, Australia, South America', () {
|
||||
// London, UK
|
||||
expect(
|
||||
WazeOsintRoadAlertProvider.determineEnvironment(latitude: 51.5074, longitude: -0.1278),
|
||||
'row',
|
||||
);
|
||||
// Paris, France
|
||||
expect(
|
||||
WazeOsintRoadAlertProvider.determineEnvironment(latitude: 48.8566, longitude: 2.3522),
|
||||
'row',
|
||||
);
|
||||
// Tokyo, Japan
|
||||
expect(
|
||||
WazeOsintRoadAlertProvider.determineEnvironment(latitude: 35.6762, longitude: 139.6503),
|
||||
'row',
|
||||
);
|
||||
// Sydney, Australia
|
||||
expect(
|
||||
WazeOsintRoadAlertProvider.determineEnvironment(latitude: -33.8688, longitude: 151.2093),
|
||||
'row',
|
||||
);
|
||||
// Sao Paulo, Brazil
|
||||
expect(
|
||||
WazeOsintRoadAlertProvider.determineEnvironment(latitude: -23.5505, longitude: -46.6333),
|
||||
'row',
|
||||
);
|
||||
// Cape Town, South Africa
|
||||
expect(
|
||||
WazeOsintRoadAlertProvider.determineEnvironment(latitude: -33.9249, longitude: 18.4241),
|
||||
'row',
|
||||
);
|
||||
});
|
||||
|
||||
test('handles coordinate edge cases, NaN and antimeridian crossing safely', () {
|
||||
expect(
|
||||
WazeOsintRoadAlertProvider.determineEnvironment(latitude: double.nan, longitude: double.nan),
|
||||
'row',
|
||||
);
|
||||
expect(
|
||||
WazeOsintRoadAlertProvider.determineEnvironment(latitude: 95.0, longitude: -100.0),
|
||||
'row',
|
||||
);
|
||||
// Western Aleutians crossing antimeridian (e.g. Attu Island at 52.9°N, 173.2°E)
|
||||
expect(
|
||||
WazeOsintRoadAlertProvider.determineEnvironment(latitude: 52.9, longitude: 173.2),
|
||||
'na',
|
||||
);
|
||||
// Normalized longitude across antimeridian (-186.8° wraps to 173.2°E)
|
||||
expect(
|
||||
WazeOsintRoadAlertProvider.determineEnvironment(latitude: 52.9, longitude: -186.8),
|
||||
'na',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('WazeOsintRoadAlertProvider Capability Gate & Network Isolation', () {
|
||||
test('isLiveNetworkAllowed is false by default in standard builds', () {
|
||||
expect(WazeOsintRoadAlertProvider.isLiveNetworkAllowed, isFalse);
|
||||
});
|
||||
|
||||
test('uninjected provider without capability gate fails closed (notConfigured & 0 requests)', () async {
|
||||
final defaultProvider = WazeOsintRoadAlertProvider();
|
||||
|
||||
// testConnection fails closed
|
||||
final status = await defaultProvider.testConnection('');
|
||||
expect(status, RoadAlertProviderStatus.notConfigured);
|
||||
|
||||
// getAlerts fails closed and returns empty without throwing
|
||||
final alerts = await defaultProvider.getAlerts(
|
||||
latitude: 35.3934,
|
||||
longitude: -83.9261,
|
||||
radiusMeters: 5000,
|
||||
);
|
||||
expect(alerts, isEmpty);
|
||||
});
|
||||
|
||||
test('injected client allows testing and fixtures without live network', () async {
|
||||
final mockClient = MockClient((request) async {
|
||||
return http.Response(json.encode({'alerts': []}), 200);
|
||||
});
|
||||
final testProvider = WazeOsintRoadAlertProvider(client: mockClient);
|
||||
|
||||
final status = await testProvider.testConnection('');
|
||||
expect(status, RoadAlertProviderStatus.connected);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,14 +29,14 @@ void main() {
|
|||
|
||||
group('WazeRoadAlertProvider Connection Tests', () {
|
||||
test('testConnection returns notConfigured for empty or whitespace key', () async {
|
||||
final provider = WazeRoadAlertProvider();
|
||||
final provider = WazeRoadAlertProvider(apiHost: 'waze-proxy.internal');
|
||||
expect(await provider.testConnection(''), equals(RoadAlertProviderStatus.notConfigured));
|
||||
expect(await provider.testConnection(' '), equals(RoadAlertProviderStatus.notConfigured));
|
||||
});
|
||||
|
||||
test('testConnection returns connected on HTTP 200 with required headers and params', () async {
|
||||
final mockClient = MockClient((request) async {
|
||||
expect(request.url.host, equals('api.openwebninja.com'));
|
||||
expect(request.url.host, equals('waze-proxy.internal'));
|
||||
expect(request.url.path, equals('/waze/alerts-and-jams'));
|
||||
expect(request.headers['x-api-key'], equals('test_waze_key'));
|
||||
expect(request.url.queryParameters['center'], equals('34.0522,-118.2437'));
|
||||
|
|
@ -47,7 +47,7 @@ void main() {
|
|||
return http.Response(json.encode({'status': 'success', 'data': {'alerts': []}}), 200);
|
||||
});
|
||||
|
||||
final provider = WazeRoadAlertProvider(client: mockClient);
|
||||
final provider = WazeRoadAlertProvider(client: mockClient, apiHost: 'waze-proxy.internal');
|
||||
final status = await provider.testConnection('test_waze_key');
|
||||
expect(status, equals(RoadAlertProviderStatus.connected));
|
||||
});
|
||||
|
|
@ -56,13 +56,13 @@ void main() {
|
|||
final mockClient401 = MockClient((request) async {
|
||||
return http.Response('{"message": "Unauthorized"}', 401);
|
||||
});
|
||||
final provider401 = WazeRoadAlertProvider(client: mockClient401);
|
||||
final provider401 = WazeRoadAlertProvider(client: mockClient401, apiHost: 'waze-proxy.internal');
|
||||
expect(await provider401.testConnection('bad_key'), equals(RoadAlertProviderStatus.authError));
|
||||
|
||||
final mockClient403 = MockClient((request) async {
|
||||
return http.Response('{"message": "Forbidden"}', 403);
|
||||
});
|
||||
final provider403 = WazeRoadAlertProvider(client: mockClient403);
|
||||
final provider403 = WazeRoadAlertProvider(client: mockClient403, apiHost: 'waze-proxy.internal');
|
||||
expect(await provider403.testConnection('forbidden_key'), equals(RoadAlertProviderStatus.authError));
|
||||
});
|
||||
|
||||
|
|
@ -70,7 +70,7 @@ void main() {
|
|||
final mockClient = MockClient((request) async {
|
||||
return http.Response('{"message": "Too Many Requests"}', 429);
|
||||
});
|
||||
final provider = WazeRoadAlertProvider(client: mockClient);
|
||||
final provider = WazeRoadAlertProvider(client: mockClient, apiHost: 'waze-proxy.internal');
|
||||
expect(await provider.testConnection('busy_key'), equals(RoadAlertProviderStatus.rateLimited));
|
||||
});
|
||||
|
||||
|
|
@ -78,13 +78,13 @@ void main() {
|
|||
final mockClient500 = MockClient((request) async {
|
||||
return http.Response('{"message": "Internal Server Error"}', 500);
|
||||
});
|
||||
final provider500 = WazeRoadAlertProvider(client: mockClient500);
|
||||
final provider500 = WazeRoadAlertProvider(client: mockClient500, apiHost: 'waze-proxy.internal');
|
||||
expect(await provider500.testConnection('key'), equals(RoadAlertProviderStatus.networkError));
|
||||
|
||||
final mockClientException = MockClient((request) async {
|
||||
throw http.ClientException('Connection reset by peer');
|
||||
});
|
||||
final providerEx = WazeRoadAlertProvider(client: mockClientException);
|
||||
final providerEx = WazeRoadAlertProvider(client: mockClientException, apiHost: 'waze-proxy.internal');
|
||||
expect(await providerEx.testConnection('key'), equals(RoadAlertProviderStatus.networkError));
|
||||
});
|
||||
});
|
||||
|
|
@ -111,9 +111,9 @@ void main() {
|
|||
|
||||
test('getAlerts enforces alert_types=POLICE, max_jams=0, and radius_units=KM', () async {
|
||||
final mockClient = MockClient((request) async {
|
||||
expect(request.url.host, equals('api.openwebninja.com'));
|
||||
expect(request.url.host, equals('waze-proxy.internal'));
|
||||
expect(request.url.path, equals('/waze/alerts-and-jams'));
|
||||
expect(request.headers['x-api-key'], equals('ninja_key_123'));
|
||||
expect(request.headers['x-api-key'], equals('custom_key_123'));
|
||||
expect(request.url.queryParameters['center'], equals('37.7749,-122.4194'));
|
||||
expect(request.url.queryParameters['radius'], equals('2.5'));
|
||||
expect(request.url.queryParameters['radius_units'], equals('KM'));
|
||||
|
|
@ -167,12 +167,12 @@ void main() {
|
|||
);
|
||||
});
|
||||
|
||||
final provider = WazeRoadAlertProvider(client: mockClient);
|
||||
final provider = WazeRoadAlertProvider(client: mockClient, apiHost: 'waze-proxy.internal');
|
||||
final alerts = await provider.getAlerts(
|
||||
latitude: 37.7749,
|
||||
longitude: -122.4194,
|
||||
radiusMeters: 2500,
|
||||
apiKey: 'ninja_key_123',
|
||||
apiKey: 'custom_key_123',
|
||||
);
|
||||
|
||||
// Only the 2 police alerts are parsed; hazard is strictly dropped
|
||||
|
|
@ -301,7 +301,7 @@ void main() {
|
|||
|
||||
test('getAlertsInBoundingBox sends bottom_left and top_right with POLICE filter', () async {
|
||||
final mockClient = MockClient((request) async {
|
||||
expect(request.url.host, equals('api.openwebninja.com'));
|
||||
expect(request.url.host, equals('waze-proxy.internal'));
|
||||
expect(request.url.path, equals('/waze/alerts-and-jams'));
|
||||
expect(request.headers['x-api-key'], equals('bbox_key'));
|
||||
expect(request.url.queryParameters['bottom_left'], equals('40.6661,-74.1373'));
|
||||
|
|
@ -330,7 +330,7 @@ void main() {
|
|||
);
|
||||
});
|
||||
|
||||
final provider = WazeRoadAlertProvider(client: mockClient);
|
||||
final provider = WazeRoadAlertProvider(client: mockClient, apiHost: 'waze-proxy.internal');
|
||||
final alerts = await provider.getAlertsInBoundingBox(
|
||||
bottomLat: 40.6661,
|
||||
bottomLng: -74.1373,
|
||||
|
|
@ -354,8 +354,8 @@ void main() {
|
|||
expect(initialKey, isEmpty, reason: 'Bundled API key must not be present by default');
|
||||
expect(initialKey, isNot(contains('ak_dud2s1987x5k7amfokrddptn41ov0iyf8kfdu3ljpv1fgvq')));
|
||||
|
||||
await container.read(wazeApiKeyProvider.notifier).setApiKey('new_ninja_key_999');
|
||||
expect(container.read(wazeApiKeyProvider), equals('new_ninja_key_999'));
|
||||
await container.read(wazeApiKeyProvider.notifier).setApiKey('custom_key_999');
|
||||
expect(container.read(wazeApiKeyProvider), equals('custom_key_999'));
|
||||
});
|
||||
|
||||
test('wazeRoadAlertsEnabledProvider defaults to true and toggles', () async {
|
||||
|
|
|
|||
Loading…
Reference in New Issue