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

View File

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

View File

@ -30,7 +30,6 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
late TextEditingController _communityKeyController;
late TextEditingController _communityEndpointController;
late TextEditingController _hereKeyController;
late TextEditingController _wazeKeyController;
bool _isTestingTomTom = false;
bool _isTestingCommunity = false;
bool _isTestingHere = false;
@ -51,9 +50,6 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
_hereKeyController = TextEditingController(
text: ref.read(hereApiKeyProvider),
);
_wazeKeyController = TextEditingController(
text: ref.read(wazeApiKeyProvider),
);
}
@override
@ -62,7 +58,6 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
_communityKeyController.dispose();
_communityEndpointController.dispose();
_hereKeyController.dispose();
_wazeKeyController.dispose();
super.dispose();
}

View File

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

View File

@ -346,13 +346,10 @@ final hereStatusProvider = StateProvider<RoadAlertProviderStatus>((ref) {
return RoadAlertProviderStatus.configured;
});
// Waze Police Alerts API Key (OpenWebNinja)
// Custom Waze Alerts API Key
class WazeApiKeyNotifier extends StateNotifier<String> {
static const _key = 'waze_api_key';
static const String defaultKey = String.fromEnvironment(
'OPENWEBNINJA_API_KEY',
defaultValue: String.fromEnvironment('WAZE_API_KEY', defaultValue: ''),
);
static const String defaultKey = String.fromEnvironment('WAZE_API_KEY', defaultValue: '');
WazeApiKeyNotifier() : super(defaultKey) {
_load();

View File

@ -289,7 +289,7 @@ class SwarmRoadAlertService {
final uid = voterUid ?? _currentUid ?? 'local_rider';
final cached = _localSwarmCache[alertId];
if (cached != null && cached.reporterUid == uid && voterUid != null) {
if (cached != null && cached.reporterUid == uid) {
if (kDebugMode) {
debugPrint('[SwarmRoadAlertService] Self-voting rejected for alert $alertId');
}
@ -305,6 +305,28 @@ class SwarmRoadAlertService {
}
_userVotes.add(voteKey);
final firestore = _firestore;
if (firestore != null) {
try {
await firestore
.collection('road_alerts')
.doc(alertId)
.collection('votes')
.doc(uid)
.set({
'voterUid': uid,
'type': 'confirm',
'votedAt': DateTime.now().millisecondsSinceEpoch,
});
} catch (e) {
if (kDebugMode) {
debugPrint('[SwarmRoadAlertService] Firestore confirm error: $e');
}
_userVotes.remove(voteKey);
return false;
}
}
if (cached != null) {
final updatedConfirmations = cached.confirmations + 1;
final newStatus = updatedConfirmations >= 2
@ -316,18 +338,6 @@ class SwarmRoadAlertService {
);
}
final firestore = _firestore;
if (firestore != null) {
try {
await firestore.collection('road_alerts').doc(alertId).update({
'confirmations': FieldValue.increment(1),
});
} catch (e) {
if (kDebugMode) {
debugPrint('[SwarmRoadAlertService] Firestore confirm error: $e');
}
}
}
return true;
}
@ -335,6 +345,15 @@ class SwarmRoadAlertService {
/// Automatically marks alert cleared/expired when dismissals reach threshold.
Future<bool> clearAlert(String alertId, {String? voterUid}) async {
final uid = voterUid ?? _currentUid ?? 'local_rider';
final cached = _localSwarmCache[alertId];
if (cached != null && cached.reporterUid == uid) {
if (kDebugMode) {
debugPrint('[SwarmRoadAlertService] Self-clearing rejected for alert $alertId');
}
return false;
}
final voteKey = '$alertId:$uid';
if (_userVotes.contains(voteKey)) {
if (kDebugMode) {
@ -344,9 +363,30 @@ class SwarmRoadAlertService {
}
_userVotes.add(voteKey);
final cached = _localSwarmCache[alertId];
final now = DateTime.now();
final firestore = _firestore;
if (firestore != null) {
try {
await firestore
.collection('road_alerts')
.doc(alertId)
.collection('votes')
.doc(uid)
.set({
'voterUid': uid,
'type': 'dismiss',
'votedAt': now.millisecondsSinceEpoch,
});
} catch (e) {
if (kDebugMode) {
debugPrint('[SwarmRoadAlertService] Firestore clear error: $e');
}
_userVotes.remove(voteKey);
return false;
}
}
if (cached != null) {
final updatedDismissals = cached.dismissals + 1;
if (updatedDismissals >= 2 && updatedDismissals >= cached.confirmations) {
@ -361,34 +401,6 @@ class SwarmRoadAlertService {
}
}
final firestore = _firestore;
if (firestore != null) {
try {
final docRef = firestore.collection('road_alerts').doc(alertId);
final doc = await docRef.get();
if (doc.exists && doc.data() != null) {
final data = doc.data()!;
final dismissals = (data['dismissals'] as num?)?.toInt() ?? 0;
final confirmations = (data['confirmations'] as num?)?.toInt() ?? 1;
if (dismissals + 1 >= 2 && (dismissals + 1) >= confirmations) {
await docRef.update({
'dismissals': FieldValue.increment(1),
'expiresAt': now.millisecondsSinceEpoch,
'status': 'cleared',
});
} else {
await docRef.update({
'dismissals': FieldValue.increment(1),
});
}
}
} catch (e) {
if (kDebugMode) {
debugPrint('[SwarmRoadAlertService] Firestore clear error: $e');
}
}
}
return true;
}
@ -454,16 +466,38 @@ class SwarmRoadAlertService {
final report = SwarmReport.fromJson(doc.data());
// Filter out cleared/expired documents
if (report.status == RoadAlertStatus.cleared ||
report.status == RoadAlertStatus.expired) {
report.status == RoadAlertStatus.expired ||
report.isExpired) {
continue;
}
// Honor local in-memory vote states and clearance
final cached = _localSwarmCache[report.id];
if (cached != null &&
(cached.status == RoadAlertStatus.cleared ||
cached.status == RoadAlertStatus.expired ||
cached.isExpired)) {
continue;
}
final effectiveReport = cached != null
? report.copyWith(
confirmations:
math.max(cached.confirmations, report.confirmations),
dismissals: math.max(cached.dismissals, report.dismissals),
status: cached.status == RoadAlertStatus.confirmed
? RoadAlertStatus.confirmed
: report.status,
)
: report;
// Client-side bounding box filter
if (report.latitude >= minLat &&
report.latitude <= maxLat &&
report.longitude >= minLng &&
report.longitude <= maxLng) {
candidates[report.id] = report;
_localSwarmCache[report.id] = report;
if (effectiveReport.latitude >= minLat &&
effectiveReport.latitude <= maxLat &&
effectiveReport.longitude >= minLng &&
effectiveReport.longitude <= maxLng) {
candidates[effectiveReport.id] = effectiveReport;
_localSwarmCache[effectiveReport.id] = effectiveReport;
}
} catch (_) {}
}

View File

@ -18,15 +18,32 @@ class WazeOsintRoadAlertProvider implements RoadAlertProvider {
final http.Client _client;
final String baseUrl;
final Duration minQueryInterval;
final List<RoadAlert>? fixtureAlerts;
final bool _allowInjectedClient;
DateTime? _lastQueryTime;
List<RoadAlert> _cachedAlerts = [];
/// Compile-time environment flag. Default is false (fails closed).
/// To enable network collection in debug mode for manual developer testing, pass:
/// `--dart-define=ENABLE_EXPERIMENTAL_WAZE_OSINT=true`
static const bool kEnableWazeOsintNetwork = bool.fromEnvironment(
'ENABLE_EXPERIMENTAL_WAZE_OSINT',
defaultValue: false,
);
/// Evaluates whether live network access to the unofficial Waze endpoint is permitted.
/// Fails closed in release mode (`kReleaseMode`), and requires explicit developer
/// compile-time authorization (`ENABLE_EXPERIMENTAL_WAZE_OSINT=true`) even in debug mode.
static bool get isLiveNetworkAllowed => kDebugMode && kEnableWazeOsintNetwork;
WazeOsintRoadAlertProvider({
http.Client? client,
this.baseUrl = 'https://www.waze.com/live-map/api/georss',
this.minQueryInterval = const Duration(seconds: 30),
}) : _client = client ?? http.Client();
this.fixtureAlerts,
}) : _client = client ?? http.Client(),
_allowInjectedClient = client != null;
@override
String get id => 'waze_osint';
@ -48,9 +65,52 @@ class WazeOsintRoadAlertProvider implements RoadAlertProvider {
RoadAlertCategory.weatherHazard,
};
/// Determines the appropriate Waze LiveMap environment (`na`, `il`, or `row`)
/// based on query coordinates.
///
/// Documented Waze LiveMap environments:
/// - `na`: North America (USA, Canada, Mexico, Puerto Rico, US Virgin Islands)
/// - `il`: Israel
/// - `row`: Rest of World (Europe, Asia, South America, Oceania, Africa)
static String determineEnvironment({
required double latitude,
required double longitude,
}) {
if (latitude.isNaN || longitude.isNaN) return 'row';
final lat = latitude.clamp(-90.0, 90.0);
// Normalize longitude across antimeridian to [-180.0, 180.0]
var lng = (longitude + 180.0) % 360.0;
if (lng < 0) lng += 360.0;
lng -= 180.0;
// Israel bounding region
if (lat >= 29.0 && lat <= 34.0 && lng >= 34.0 && lng <= 36.5) {
return 'il';
}
// North America bounding region:
// USA (including Alaska, Hawaii, Puerto Rico), Canada, Mexico, Caribbean
// Lat: 10.0°N to 75.0°N, Lng: -180.0°W to -50.0°W
// Aleutian Islands crossing the antimeridian: Lat 50.0°N to 56.0°N, Lng 170.0°E to 180.0°E
if (lat >= 10.0 && lat <= 75.0) {
if (lng >= -180.0 && lng <= -50.0) {
return 'na';
}
if (lat >= 50.0 && lat <= 56.0 && lng >= 170.0 && lng <= 180.0) {
return 'na';
}
}
return 'row';
}
@override
Future<RoadAlertProviderStatus> testConnection(String apiKey) async {
// OSINT adapter is community-sourced and does not require an API key
// If live network is not permitted by capability gate and no test client is injected:
if (!isLiveNetworkAllowed && !_allowInjectedClient) {
return RoadAlertProviderStatus.notConfigured;
}
return RoadAlertProviderStatus.connected;
}
@ -61,6 +121,23 @@ class WazeOsintRoadAlertProvider implements RoadAlertProvider {
required double radiusMeters,
String? apiKey,
}) async {
// Capability gate: Live network requests are strictly forbidden in production
// and release configurations. In debug mode, they require explicit compile-time
// developer authorization (ENABLE_EXPERIMENTAL_WAZE_OSINT=true).
final canPerformNetworkQuery = isLiveNetworkAllowed || _allowInjectedClient;
if (!canPerformNetworkQuery) {
if (kDebugMode) {
debugPrint(
'[WazeOsintRoadAlertProvider] Network access disabled by capability gate. '
'Release builds and standard debug builds do not execute live Waze requests.',
);
}
if (fixtureAlerts != null) {
return _filterByRadius(fixtureAlerts!, latitude, longitude, radiusMeters);
}
return _filterByRadius(_cachedAlerts, latitude, longitude, radiusMeters);
}
final now = DateTime.now();
// Respect client rate limiting interval to prevent hammering public endpoints
@ -76,8 +153,21 @@ class WazeOsintRoadAlertProvider implements RoadAlertProvider {
final top = (latitude + latDelta).clamp(-90.0, 90.0);
final bottom = (latitude - latDelta).clamp(-90.0, 90.0);
final left = (longitude - lngDelta).clamp(-180.0, 180.0);
final right = (longitude + lngDelta).clamp(-180.0, 180.0);
// Handle longitude bounds with antimeridian wrapping and clamping
double left = longitude - lngDelta;
double right = longitude + lngDelta;
if (left < -180.0) left += 360.0;
if (right > 180.0) right -= 360.0;
if (left > right) {
left = -180.0;
right = 180.0;
} else {
left = left.clamp(-180.0, 180.0);
right = right.clamp(-180.0, 180.0);
}
final env = determineEnvironment(latitude: latitude, longitude: longitude);
final uri = Uri.parse(baseUrl).replace(
queryParameters: {
@ -85,7 +175,7 @@ class WazeOsintRoadAlertProvider implements RoadAlertProvider {
'bottom': bottom.toStringAsFixed(6),
'left': left.toStringAsFixed(6),
'right': right.toStringAsFixed(6),
'env': 'row',
'env': env,
'types': 'alerts',
},
);

View File

@ -4,10 +4,9 @@ import 'package:http/http.dart' as http;
import 'road_alert_models.dart';
import 'road_alert_provider_interface.dart';
/// Real-time Waze Police Alerts provider via OpenWebNinja.
/// Optional custom Waze Police Alerts provider for user-configured REST endpoints.
///
/// Queries OpenWebNinja's Waze REST endpoint exclusively for police alerts
/// (`alert_types=POLICE`, `max_jams=0`) around the rider's coordinate.
/// Requires a user-provided custom `apiHost` or `WAZE_API_HOST` environment variable.
class WazeRoadAlertProvider implements RoadAlertProvider {
final http.Client _client;
final String apiHost;
@ -19,10 +18,7 @@ class WazeRoadAlertProvider implements RoadAlertProvider {
apiHost = apiHost ??
const String.fromEnvironment(
'WAZE_API_HOST',
defaultValue: String.fromEnvironment(
'OPENWEBNINJA_API_HOST',
defaultValue: 'api.openwebninja.com',
),
defaultValue: '',
);
@override
@ -39,7 +35,7 @@ class WazeRoadAlertProvider implements RoadAlertProvider {
@override
Future<RoadAlertProviderStatus> testConnection(String apiKey) async {
final key = apiKey.trim();
if (key.isEmpty) {
if (key.isEmpty || apiHost.isEmpty) {
return RoadAlertProviderStatus.notConfigured;
}
@ -84,7 +80,7 @@ class WazeRoadAlertProvider implements RoadAlertProvider {
String? apiKey,
}) async {
final key = apiKey?.trim();
if (key == null || key.isEmpty) {
if (key == null || key.isEmpty || apiHost.isEmpty) {
return [];
}

View File

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

View File

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

View File

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