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