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 alice = testEnv.authenticatedContext('alice').firestore();
|
||||||
const bob = testEnv.authenticatedContext('bob').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 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 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
|
const voteRefBob = doc(bob, `road_alerts/${alertId}/votes/bob`);
|
||||||
await assertFails(updateDoc(alertRefSignedOut, {
|
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,
|
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,
|
dismissals: 1,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Attempting to inflate confirmations from 1 to 5 (> 1 increment)
|
// Reporter Alice also cannot directly mutate dismissals
|
||||||
await assertFails(updateDoc(alertRefBob, {
|
await assertFails(updateDoc(alertRefAlice, {
|
||||||
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,
|
|
||||||
dismissals: 1,
|
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 alice = testEnv.authenticatedContext('alice').firestore();
|
||||||
const bob = testEnv.authenticatedContext('bob').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 assertSucceeds(setDoc(alertRefAlice, {
|
||||||
await assertFails(updateDoc(alertRefBob, {
|
...baseAlert,
|
||||||
confirmations: 2,
|
id: alertId,
|
||||||
expiresAt: 9999999,
|
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, {
|
await assertFails(updateDoc(alertRefBob, {
|
||||||
confirmations: 2,
|
expiresAt: Date.now() + 9999999,
|
||||||
expiresAt: 1001000,
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Extending expiresAt beyond current expiresAt during dismissal
|
// Bob tries setting status to cleared
|
||||||
await assertFails(updateDoc(alertRefBob, {
|
await assertFails(updateDoc(alertRefBob, {
|
||||||
dismissals: 1,
|
status: 'cleared',
|
||||||
expiresAt: 1005000,
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Setting expiresAt earlier than reportedAt during dismissal
|
// Alice (reporter) also cannot update status or expiresAt
|
||||||
await assertFails(updateDoc(alertRefBob, {
|
await assertFails(updateDoc(alertRefAlice, {
|
||||||
dismissals: 1,
|
status: 'cleared',
|
||||||
expiresAt: 999999,
|
|
||||||
}));
|
}));
|
||||||
|
await assertFails(updateDoc(alertRefAlice, {
|
||||||
// Manipulating expiresAt without voting (neither confirmations nor dismissals incremented)
|
expiresAt: Date.now() + 9999999,
|
||||||
await assertFails(updateDoc(alertRefBob, {
|
|
||||||
expiresAt: 1001000,
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Manipulating expiresAt to far future with no vote
|
|
||||||
await assertFails(updateDoc(alertRefBob, {
|
|
||||||
expiresAt: 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 alice = testEnv.authenticatedContext('alice').firestore();
|
||||||
const bob = testEnv.authenticatedContext('bob').firestore();
|
const bob = testEnv.authenticatedContext('bob').firestore();
|
||||||
const alertRefAlice = doc(alice, 'road_alerts/alert_hazard_101');
|
const charlie = testEnv.authenticatedContext('charlie').firestore();
|
||||||
const alertRefBob = doc(bob, 'road_alerts/alert_hazard_101');
|
|
||||||
|
|
||||||
await assertSucceeds(setDoc(alertRefAlice, baseAlert));
|
const alertId = 'alert_hazard_distinct_voters_1';
|
||||||
|
await assertSucceeds(setDoc(doc(alice, `road_alerts/${alertId}`), {
|
||||||
// Attempting to change reporterUid during vote
|
...baseAlert,
|
||||||
await assertFails(updateDoc(alertRefBob, {
|
id: alertId,
|
||||||
confirmations: 2,
|
reportedAt: Date.now() - 1000,
|
||||||
reporterUid: 'bob',
|
expiresAt: Date.now() + 3600000,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Attempting to change category during vote
|
const voteRefBob = doc(bob, `road_alerts/${alertId}/votes/bob`);
|
||||||
await assertFails(updateDoc(alertRefBob, {
|
const voteRefCharlie = doc(charlie, `road_alerts/${alertId}/votes/charlie`);
|
||||||
confirmations: 2,
|
|
||||||
category: 'police',
|
await assertSucceeds(setDoc(voteRefBob, {
|
||||||
|
voterUid: 'bob',
|
||||||
|
type: 'confirm',
|
||||||
|
votedAt: Date.now(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Attempting to change latitude during vote
|
await assertSucceeds(setDoc(voteRefCharlie, {
|
||||||
await assertFails(updateDoc(alertRefBob, {
|
voterUid: 'charlie',
|
||||||
confirmations: 2,
|
type: 'dismiss',
|
||||||
latitude: 38.0,
|
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
|
const voteRefBob = doc(bob, `road_alerts/${alertId}/votes/bob`);
|
||||||
await assertFails(updateDoc(alertRefBob, {
|
await assertFails(setDoc(voteRefBob, {
|
||||||
confirmations: 2,
|
voterUid: 'bob',
|
||||||
reportedAt: 900000,
|
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
|
const voteRefBob = doc(bob, `road_alerts/${alertId}/votes/bob`);
|
||||||
await assertFails(updateDoc(alertRefBob, {
|
await assertFails(setDoc(voteRefBob, {
|
||||||
confirmations: 2,
|
voterUid: 'bob',
|
||||||
newField: 'exploit',
|
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
|
const voteRefBob = doc(bob, `road_alerts/${alertId}/votes/bob`);
|
||||||
await assertFails(updateDoc(alertRefBob, {
|
|
||||||
confirmations: 2,
|
// Unknown extra keys
|
||||||
description: 'Modified description',
|
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),
|
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']);
|
&& (!('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 read: if signedIn();
|
||||||
allow create: if validAlertCreate();
|
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;
|
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=**} {
|
match /{document=**} {
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,6 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
||||||
late TextEditingController _communityKeyController;
|
late TextEditingController _communityKeyController;
|
||||||
late TextEditingController _communityEndpointController;
|
late TextEditingController _communityEndpointController;
|
||||||
late TextEditingController _hereKeyController;
|
late TextEditingController _hereKeyController;
|
||||||
late TextEditingController _wazeKeyController;
|
|
||||||
bool _isTestingTomTom = false;
|
bool _isTestingTomTom = false;
|
||||||
bool _isTestingCommunity = false;
|
bool _isTestingCommunity = false;
|
||||||
bool _isTestingHere = false;
|
bool _isTestingHere = false;
|
||||||
|
|
@ -51,9 +50,6 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
||||||
_hereKeyController = TextEditingController(
|
_hereKeyController = TextEditingController(
|
||||||
text: ref.read(hereApiKeyProvider),
|
text: ref.read(hereApiKeyProvider),
|
||||||
);
|
);
|
||||||
_wazeKeyController = TextEditingController(
|
|
||||||
text: ref.read(wazeApiKeyProvider),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -62,7 +58,6 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
||||||
_communityKeyController.dispose();
|
_communityKeyController.dispose();
|
||||||
_communityEndpointController.dispose();
|
_communityEndpointController.dispose();
|
||||||
_hereKeyController.dispose();
|
_hereKeyController.dispose();
|
||||||
_wazeKeyController.dispose();
|
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -88,7 +88,7 @@ class CommunityTrafficRoadAlertProvider implements RoadAlertProvider {
|
||||||
return RoadAlertProviderStatus.networkError;
|
return RoadAlertProviderStatus.networkError;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. If it's a RapidAPI / OpenWebNinja key format
|
// 3. If it's a RapidAPI key format
|
||||||
if (_isRapidApiKey(key)) {
|
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 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: {
|
final response = await http.get(uri, headers: {
|
||||||
|
|
|
||||||
|
|
@ -346,13 +346,10 @@ final hereStatusProvider = StateProvider<RoadAlertProviderStatus>((ref) {
|
||||||
return RoadAlertProviderStatus.configured;
|
return RoadAlertProviderStatus.configured;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Waze Police Alerts API Key (OpenWebNinja)
|
// Custom Waze Alerts API Key
|
||||||
class WazeApiKeyNotifier extends StateNotifier<String> {
|
class WazeApiKeyNotifier extends StateNotifier<String> {
|
||||||
static const _key = 'waze_api_key';
|
static const _key = 'waze_api_key';
|
||||||
static const String defaultKey = String.fromEnvironment(
|
static const String defaultKey = String.fromEnvironment('WAZE_API_KEY', defaultValue: '');
|
||||||
'OPENWEBNINJA_API_KEY',
|
|
||||||
defaultValue: String.fromEnvironment('WAZE_API_KEY', defaultValue: ''),
|
|
||||||
);
|
|
||||||
|
|
||||||
WazeApiKeyNotifier() : super(defaultKey) {
|
WazeApiKeyNotifier() : super(defaultKey) {
|
||||||
_load();
|
_load();
|
||||||
|
|
|
||||||
|
|
@ -289,7 +289,7 @@ class SwarmRoadAlertService {
|
||||||
final uid = voterUid ?? _currentUid ?? 'local_rider';
|
final uid = voterUid ?? _currentUid ?? 'local_rider';
|
||||||
final cached = _localSwarmCache[alertId];
|
final cached = _localSwarmCache[alertId];
|
||||||
|
|
||||||
if (cached != null && cached.reporterUid == uid && voterUid != null) {
|
if (cached != null && cached.reporterUid == uid) {
|
||||||
if (kDebugMode) {
|
if (kDebugMode) {
|
||||||
debugPrint('[SwarmRoadAlertService] Self-voting rejected for alert $alertId');
|
debugPrint('[SwarmRoadAlertService] Self-voting rejected for alert $alertId');
|
||||||
}
|
}
|
||||||
|
|
@ -305,6 +305,28 @@ class SwarmRoadAlertService {
|
||||||
}
|
}
|
||||||
_userVotes.add(voteKey);
|
_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) {
|
if (cached != null) {
|
||||||
final updatedConfirmations = cached.confirmations + 1;
|
final updatedConfirmations = cached.confirmations + 1;
|
||||||
final newStatus = updatedConfirmations >= 2
|
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;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -335,6 +345,15 @@ class SwarmRoadAlertService {
|
||||||
/// Automatically marks alert cleared/expired when dismissals reach threshold.
|
/// Automatically marks alert cleared/expired when dismissals reach threshold.
|
||||||
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];
|
||||||
|
|
||||||
|
if (cached != null && cached.reporterUid == uid) {
|
||||||
|
if (kDebugMode) {
|
||||||
|
debugPrint('[SwarmRoadAlertService] Self-clearing rejected for alert $alertId');
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
final voteKey = '$alertId:$uid';
|
final voteKey = '$alertId:$uid';
|
||||||
if (_userVotes.contains(voteKey)) {
|
if (_userVotes.contains(voteKey)) {
|
||||||
if (kDebugMode) {
|
if (kDebugMode) {
|
||||||
|
|
@ -344,9 +363,30 @@ class SwarmRoadAlertService {
|
||||||
}
|
}
|
||||||
_userVotes.add(voteKey);
|
_userVotes.add(voteKey);
|
||||||
|
|
||||||
final cached = _localSwarmCache[alertId];
|
|
||||||
final now = DateTime.now();
|
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) {
|
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) {
|
||||||
|
|
@ -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;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -454,16 +466,38 @@ class SwarmRoadAlertService {
|
||||||
final report = SwarmReport.fromJson(doc.data());
|
final report = SwarmReport.fromJson(doc.data());
|
||||||
// Filter out cleared/expired documents
|
// Filter out cleared/expired documents
|
||||||
if (report.status == RoadAlertStatus.cleared ||
|
if (report.status == RoadAlertStatus.cleared ||
|
||||||
report.status == RoadAlertStatus.expired) {
|
report.status == RoadAlertStatus.expired ||
|
||||||
|
report.isExpired) {
|
||||||
continue;
|
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
|
// Client-side bounding box filter
|
||||||
if (report.latitude >= minLat &&
|
if (effectiveReport.latitude >= minLat &&
|
||||||
report.latitude <= maxLat &&
|
effectiveReport.latitude <= maxLat &&
|
||||||
report.longitude >= minLng &&
|
effectiveReport.longitude >= minLng &&
|
||||||
report.longitude <= maxLng) {
|
effectiveReport.longitude <= maxLng) {
|
||||||
candidates[report.id] = report;
|
candidates[effectiveReport.id] = effectiveReport;
|
||||||
_localSwarmCache[report.id] = report;
|
_localSwarmCache[effectiveReport.id] = effectiveReport;
|
||||||
}
|
}
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,15 +18,32 @@ class WazeOsintRoadAlertProvider implements RoadAlertProvider {
|
||||||
final http.Client _client;
|
final http.Client _client;
|
||||||
final String baseUrl;
|
final String baseUrl;
|
||||||
final Duration minQueryInterval;
|
final Duration minQueryInterval;
|
||||||
|
final List<RoadAlert>? fixtureAlerts;
|
||||||
|
final bool _allowInjectedClient;
|
||||||
|
|
||||||
DateTime? _lastQueryTime;
|
DateTime? _lastQueryTime;
|
||||||
List<RoadAlert> _cachedAlerts = [];
|
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({
|
WazeOsintRoadAlertProvider({
|
||||||
http.Client? client,
|
http.Client? client,
|
||||||
this.baseUrl = 'https://www.waze.com/live-map/api/georss',
|
this.baseUrl = 'https://www.waze.com/live-map/api/georss',
|
||||||
this.minQueryInterval = const Duration(seconds: 30),
|
this.minQueryInterval = const Duration(seconds: 30),
|
||||||
}) : _client = client ?? http.Client();
|
this.fixtureAlerts,
|
||||||
|
}) : _client = client ?? http.Client(),
|
||||||
|
_allowInjectedClient = client != null;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get id => 'waze_osint';
|
String get id => 'waze_osint';
|
||||||
|
|
@ -48,9 +65,52 @@ class WazeOsintRoadAlertProvider implements RoadAlertProvider {
|
||||||
RoadAlertCategory.weatherHazard,
|
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
|
@override
|
||||||
Future<RoadAlertProviderStatus> testConnection(String apiKey) async {
|
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;
|
return RoadAlertProviderStatus.connected;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -61,6 +121,23 @@ class WazeOsintRoadAlertProvider implements RoadAlertProvider {
|
||||||
required double radiusMeters,
|
required double radiusMeters,
|
||||||
String? apiKey,
|
String? apiKey,
|
||||||
}) async {
|
}) 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();
|
final now = DateTime.now();
|
||||||
|
|
||||||
// Respect client rate limiting interval to prevent hammering public endpoints
|
// 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 top = (latitude + latDelta).clamp(-90.0, 90.0);
|
||||||
final bottom = (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(
|
final uri = Uri.parse(baseUrl).replace(
|
||||||
queryParameters: {
|
queryParameters: {
|
||||||
|
|
@ -85,7 +175,7 @@ class WazeOsintRoadAlertProvider implements RoadAlertProvider {
|
||||||
'bottom': bottom.toStringAsFixed(6),
|
'bottom': bottom.toStringAsFixed(6),
|
||||||
'left': left.toStringAsFixed(6),
|
'left': left.toStringAsFixed(6),
|
||||||
'right': right.toStringAsFixed(6),
|
'right': right.toStringAsFixed(6),
|
||||||
'env': 'row',
|
'env': env,
|
||||||
'types': 'alerts',
|
'types': 'alerts',
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -4,10 +4,9 @@ import 'package:http/http.dart' as http;
|
||||||
import 'road_alert_models.dart';
|
import 'road_alert_models.dart';
|
||||||
import 'road_alert_provider_interface.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
|
/// Requires a user-provided custom `apiHost` or `WAZE_API_HOST` environment variable.
|
||||||
/// (`alert_types=POLICE`, `max_jams=0`) around the rider's coordinate.
|
|
||||||
class WazeRoadAlertProvider implements RoadAlertProvider {
|
class WazeRoadAlertProvider implements RoadAlertProvider {
|
||||||
final http.Client _client;
|
final http.Client _client;
|
||||||
final String apiHost;
|
final String apiHost;
|
||||||
|
|
@ -19,10 +18,7 @@ class WazeRoadAlertProvider implements RoadAlertProvider {
|
||||||
apiHost = apiHost ??
|
apiHost = apiHost ??
|
||||||
const String.fromEnvironment(
|
const String.fromEnvironment(
|
||||||
'WAZE_API_HOST',
|
'WAZE_API_HOST',
|
||||||
defaultValue: String.fromEnvironment(
|
defaultValue: '',
|
||||||
'OPENWEBNINJA_API_HOST',
|
|
||||||
defaultValue: 'api.openwebninja.com',
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -39,7 +35,7 @@ class WazeRoadAlertProvider implements RoadAlertProvider {
|
||||||
@override
|
@override
|
||||||
Future<RoadAlertProviderStatus> testConnection(String apiKey) async {
|
Future<RoadAlertProviderStatus> testConnection(String apiKey) async {
|
||||||
final key = apiKey.trim();
|
final key = apiKey.trim();
|
||||||
if (key.isEmpty) {
|
if (key.isEmpty || apiHost.isEmpty) {
|
||||||
return RoadAlertProviderStatus.notConfigured;
|
return RoadAlertProviderStatus.notConfigured;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -84,7 +80,7 @@ class WazeRoadAlertProvider implements RoadAlertProvider {
|
||||||
String? apiKey,
|
String? apiKey,
|
||||||
}) async {
|
}) async {
|
||||||
final key = apiKey?.trim();
|
final key = apiKey?.trim();
|
||||||
if (key == null || key.isEmpty) {
|
if (key == null || key.isEmpty || apiHost.isEmpty) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -210,37 +210,61 @@ void main() {
|
||||||
expect(expiresAt.difference(reportedAt).inMinutes, 120);
|
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(
|
final report = await service.reportAlert(
|
||||||
category: RoadAlertCategory.police,
|
category: RoadAlertCategory.police,
|
||||||
latitude: 34.0,
|
latitude: 34.0,
|
||||||
longitude: -118.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();
|
final voteDoc = await fakeFirestore
|
||||||
expect(doc.data()!['confirmations'], 2);
|
.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(
|
final report = await service.reportAlert(
|
||||||
category: RoadAlertCategory.hazard,
|
category: RoadAlertCategory.hazard,
|
||||||
latitude: 34.0,
|
latitude: 34.0,
|
||||||
longitude: -118.0,
|
longitude: -118.0,
|
||||||
);
|
);
|
||||||
|
|
||||||
// 1st clear: increments dismissals to 1
|
// 1st clear: increments dismissals to 1 and stores vote in subcollection
|
||||||
await service.clearAlert(report.id, voterUid: 'rider_a');
|
final res1 = await service.clearAlert(report.id, voterUid: 'rider_a');
|
||||||
var doc = await fakeFirestore.collection('road_alerts').doc(report.id).get();
|
expect(res1, isTrue);
|
||||||
expect(doc.data()!['dismissals'], 1);
|
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
|
// 2nd clear: dismissals becomes 2, which is >= confirmations (1) -> expires immediately
|
||||||
await service.clearAlert(report.id, voterUid: 'rider_b');
|
final res2 = await service.clearAlert(report.id, voterUid: 'rider_b');
|
||||||
doc = await fakeFirestore.collection('road_alerts').doc(report.id).get();
|
expect(res2, isTrue);
|
||||||
expect(doc.data()!['dismissals'], 2);
|
final voteB = await fakeFirestore
|
||||||
final expiresAt = doc.data()!['expiresAt'] as int;
|
.collection('road_alerts')
|
||||||
expect(DateTime.now().millisecondsSinceEpoch >= expiresAt - 1000, isTrue);
|
.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 {
|
test('getAlertsNear finds alerts within radius and excludes distant ones', () async {
|
||||||
|
|
@ -496,9 +520,16 @@ void main() {
|
||||||
expect(duplicateMerged.id, first.id);
|
expect(duplicateMerged.id, first.id);
|
||||||
expect(duplicateMerged.confirmations, 2);
|
expect(duplicateMerged.confirmations, 2);
|
||||||
|
|
||||||
// Check Firestore doc has 2 confirmations
|
// Check vote subcollection has confirmation vote from rider_2
|
||||||
final doc = await fakeFirestore.collection('road_alerts').doc(first.id).get();
|
final voteDoc = await fakeFirestore
|
||||||
expect(doc.data()!['confirmations'], 2);
|
.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
|
// Distant report (500m away: lat 35.0050) creates a separate alert
|
||||||
final separate = await service.reportAlert(
|
final separate = await service.reportAlert(
|
||||||
|
|
|
||||||
|
|
@ -219,10 +219,157 @@ void main() {
|
||||||
expect(alerts2.length, 1);
|
expect(alerts2.length, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('testConnection returns connected without requiring paid API key', () async {
|
test('testConnection with injected client returns connected', () async {
|
||||||
final provider = WazeOsintRoadAlertProvider();
|
final mockClient = MockClient((request) async {
|
||||||
|
return http.Response(json.encode({'alerts': []}), 200);
|
||||||
|
});
|
||||||
|
final provider = WazeOsintRoadAlertProvider(client: mockClient);
|
||||||
final status = await provider.testConnection('');
|
final status = await provider.testConnection('');
|
||||||
expect(status, RoadAlertProviderStatus.connected);
|
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', () {
|
group('WazeRoadAlertProvider Connection Tests', () {
|
||||||
test('testConnection returns notConfigured for empty or whitespace key', () async {
|
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));
|
||||||
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 {
|
test('testConnection returns connected on HTTP 200 with required headers and params', () async {
|
||||||
final mockClient = MockClient((request) 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.url.path, equals('/waze/alerts-and-jams'));
|
||||||
expect(request.headers['x-api-key'], equals('test_waze_key'));
|
expect(request.headers['x-api-key'], equals('test_waze_key'));
|
||||||
expect(request.url.queryParameters['center'], equals('34.0522,-118.2437'));
|
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);
|
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');
|
final status = await provider.testConnection('test_waze_key');
|
||||||
expect(status, equals(RoadAlertProviderStatus.connected));
|
expect(status, equals(RoadAlertProviderStatus.connected));
|
||||||
});
|
});
|
||||||
|
|
@ -56,13 +56,13 @@ void main() {
|
||||||
final mockClient401 = MockClient((request) async {
|
final mockClient401 = MockClient((request) async {
|
||||||
return http.Response('{"message": "Unauthorized"}', 401);
|
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));
|
expect(await provider401.testConnection('bad_key'), equals(RoadAlertProviderStatus.authError));
|
||||||
|
|
||||||
final mockClient403 = MockClient((request) async {
|
final mockClient403 = MockClient((request) async {
|
||||||
return http.Response('{"message": "Forbidden"}', 403);
|
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));
|
expect(await provider403.testConnection('forbidden_key'), equals(RoadAlertProviderStatus.authError));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -70,7 +70,7 @@ void main() {
|
||||||
final mockClient = MockClient((request) async {
|
final mockClient = MockClient((request) async {
|
||||||
return http.Response('{"message": "Too Many Requests"}', 429);
|
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));
|
expect(await provider.testConnection('busy_key'), equals(RoadAlertProviderStatus.rateLimited));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -78,13 +78,13 @@ void main() {
|
||||||
final mockClient500 = MockClient((request) async {
|
final mockClient500 = MockClient((request) async {
|
||||||
return http.Response('{"message": "Internal Server Error"}', 500);
|
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));
|
expect(await provider500.testConnection('key'), equals(RoadAlertProviderStatus.networkError));
|
||||||
|
|
||||||
final mockClientException = MockClient((request) async {
|
final mockClientException = MockClient((request) async {
|
||||||
throw http.ClientException('Connection reset by peer');
|
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));
|
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 {
|
test('getAlerts enforces alert_types=POLICE, max_jams=0, and radius_units=KM', () async {
|
||||||
final mockClient = MockClient((request) 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.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['center'], equals('37.7749,-122.4194'));
|
||||||
expect(request.url.queryParameters['radius'], equals('2.5'));
|
expect(request.url.queryParameters['radius'], equals('2.5'));
|
||||||
expect(request.url.queryParameters['radius_units'], equals('KM'));
|
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(
|
final alerts = await provider.getAlerts(
|
||||||
latitude: 37.7749,
|
latitude: 37.7749,
|
||||||
longitude: -122.4194,
|
longitude: -122.4194,
|
||||||
radiusMeters: 2500,
|
radiusMeters: 2500,
|
||||||
apiKey: 'ninja_key_123',
|
apiKey: 'custom_key_123',
|
||||||
);
|
);
|
||||||
|
|
||||||
// Only the 2 police alerts are parsed; hazard is strictly dropped
|
// 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 {
|
test('getAlertsInBoundingBox sends bottom_left and top_right with POLICE filter', () async {
|
||||||
final mockClient = MockClient((request) 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.url.path, equals('/waze/alerts-and-jams'));
|
||||||
expect(request.headers['x-api-key'], equals('bbox_key'));
|
expect(request.headers['x-api-key'], equals('bbox_key'));
|
||||||
expect(request.url.queryParameters['bottom_left'], equals('40.6661,-74.1373'));
|
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(
|
final alerts = await provider.getAlertsInBoundingBox(
|
||||||
bottomLat: 40.6661,
|
bottomLat: 40.6661,
|
||||||
bottomLng: -74.1373,
|
bottomLng: -74.1373,
|
||||||
|
|
@ -354,8 +354,8 @@ void main() {
|
||||||
expect(initialKey, isEmpty, reason: 'Bundled API key must not be present by default');
|
expect(initialKey, isEmpty, reason: 'Bundled API key must not be present by default');
|
||||||
expect(initialKey, isNot(contains('ak_dud2s1987x5k7amfokrddptn41ov0iyf8kfdu3ljpv1fgvq')));
|
expect(initialKey, isNot(contains('ak_dud2s1987x5k7amfokrddptn41ov0iyf8kfdu3ljpv1fgvq')));
|
||||||
|
|
||||||
await container.read(wazeApiKeyProvider.notifier).setApiKey('new_ninja_key_999');
|
await container.read(wazeApiKeyProvider.notifier).setApiKey('custom_key_999');
|
||||||
expect(container.read(wazeApiKeyProvider), equals('new_ninja_key_999'));
|
expect(container.read(wazeApiKeyProvider), equals('custom_key_999'));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('wazeRoadAlertsEnabledProvider defaults to true and toggles', () async {
|
test('wazeRoadAlertsEnabledProvider defaults to true and toggles', () async {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue