fix(alerts): harden road alert voting, isolate Waze OSINT adapter, purge OpenWebNinja

This commit is contained in:
Developer 2026-10-10 02:58:15 -04:00
parent 7bd5a0e585
commit de94bbb3e3
11 changed files with 775 additions and 317 deletions

View File

@ -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',
}));
});
}); });

View File

@ -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=**} {

View File

@ -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();
} }

View File

@ -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: {

View File

@ -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();

View File

@ -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 (_) {}
} }

View File

@ -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',
}, },
); );

View File

@ -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 [];
} }

View File

@ -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(

View File

@ -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);
});
});
} }

View File

@ -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 {