fix(alerts): remediate P0 gateway auth, P1 Zyla schema, and budget safeguards (#31, #32)

This commit is contained in:
Developer 2026-10-10 18:23:32 -04:00
parent 625999245e
commit 9f73fd4cd4
12 changed files with 989 additions and 388 deletions

View File

@ -1,7 +1,9 @@
import math import math
import os
import time import time
from typing import Any, Optional
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Optional
class GeographicCellCache: class GeographicCellCache:
@ -107,3 +109,98 @@ class CircuitBreaker:
self.failure_count = 0 self.failure_count = 0
self.state = "CLOSED" self.state = "CLOSED"
self.last_error_message = None self.last_error_message = None
class PersistentUsageTracker:
"""
Durable monthly usage and request-budget tracker.
Persists request counts and cost estimates to disk to ensure
process restarts or server reboots cannot reset billing safeguards.
"""
def __init__(
self,
provider_id: str,
monthly_budget_limit: int = 500,
cost_per_request_usd: float = 0.005,
state_file: Optional[Any] = None,
):
from pathlib import Path
import json
self.provider_id = provider_id
self.monthly_budget_limit = monthly_budget_limit
self.cost_per_request_usd = cost_per_request_usd
if state_file is not None:
self.state_file = Path(state_file)
else:
base_dir = Path(os.environ.get("USAGE_STATE_DIR", Path(__file__).resolve().parent.parent.parent / "storage"))
self.state_file = base_dir / f"usage_{provider_id}.json"
from threading import Lock
self.lock = Lock()
self.current_period = datetime.now(timezone.utc).strftime("%Y-%m")
self.requests_used: int = 0
self.total_lifetime_requests: int = 0
self._load()
def _load(self) -> None:
try:
if self.state_file.exists():
import json
with open(self.state_file, "r", encoding="utf-8") as f:
data = json.load(f)
saved_period = data.get("period")
if saved_period == self.current_period:
self.requests_used = int(data.get("requests_used", 0))
else:
self.requests_used = 0
self.total_lifetime_requests = int(data.get("lifetime_requests", self.requests_used))
except Exception:
pass
def _save(self) -> None:
try:
import json
self.state_file.parent.mkdir(parents=True, exist_ok=True)
tmp_path = self.state_file.with_suffix(".tmp")
data = {
"provider_id": self.provider_id,
"period": self.current_period,
"requests_used": self.requests_used,
"lifetime_requests": self.total_lifetime_requests,
"monthly_budget_limit": self.monthly_budget_limit,
"estimated_cost_usd": round(self.requests_used * self.cost_per_request_usd, 4),
"updated_at": datetime.now(timezone.utc).isoformat(),
}
with open(tmp_path, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2)
tmp_path.replace(self.state_file)
except Exception:
pass
def check_period(self) -> None:
now_period = datetime.now(timezone.utc).strftime("%Y-%m")
if now_period != self.current_period:
self.current_period = now_period
self.requests_used = 0
self._save()
def can_request(self) -> bool:
with self.lock:
self.check_period()
return self.requests_used < self.monthly_budget_limit
def record_request(self) -> int:
with self.lock:
self.check_period()
self.requests_used += 1
self.total_lifetime_requests += 1
self._save()
return self.requests_used
@property
def estimated_cost_usd(self) -> float:
return round(self.requests_used * self.cost_per_request_usd, 4)

View File

@ -6,9 +6,12 @@ from pydantic import BaseModel, Field, field_validator
class NormalizedAlert(BaseModel): class NormalizedAlert(BaseModel):
id: str = Field(description="Normalized unique alert identifier") id: str = Field(description="Normalized unique alert identifier")
source: str = Field(description="Attributed data source name") source: str = Field(description="Attributed data source name")
source_type: Literal["native_rider", "licensed_external", "official_dot"] = Field( source_type: Literal[
description="Source classification" "native_rider",
) "experimental_external",
"licensed_external",
"official_dot",
] = Field(description="Source classification")
category: Literal[ category: Literal[
"police", "police",
"accident", "accident",
@ -29,12 +32,15 @@ class NormalizedAlert(BaseModel):
reported_at_utc: str = Field(description="ISO 8601 UTC creation timestamp") reported_at_utc: str = Field(description="ISO 8601 UTC creation timestamp")
fetched_at_utc: str = Field(description="ISO 8601 UTC ingest timestamp") fetched_at_utc: str = Field(description="ISO 8601 UTC ingest timestamp")
expires_at_utc: str = Field(description="ISO 8601 UTC expiration timestamp") expires_at_utc: str = Field(description="ISO 8601 UTC expiration timestamp")
confidence: float = Field(ge=0.0, le=1.0, description="Normalized confidence score (0.0 to 1.0)") confidence: Optional[float] = Field(
default=None, ge=0.0, le=1.0, description="Normalized confidence score (0.0 to 1.0)"
)
reliability: Optional[float] = Field(default=None, description="Source reliability score") reliability: Optional[float] = Field(default=None, description="Source reliability score")
is_stale: bool = Field(default=False, description="Flag indicating if record exceeds freshness TTL") is_stale: bool = Field(default=False, description="Flag indicating if record exceeds freshness TTL")
raw_provider_id: Optional[str] = Field(default=None, description="Original upstream provider/alert ID") raw_provider_id: Optional[str] = Field(default=None, description="Original upstream provider/alert ID")
class AlertFeedResponse(BaseModel): class AlertFeedResponse(BaseModel):
success: bool success: bool
count: int count: int

View File

@ -63,25 +63,133 @@ def verify_rate_limit():
) )
def verify_gateway_auth( # Set of permitted static test fixture filenames
ALLOWED_FIXTURES = {"zyla_rome_sample.json", "zyla_atlanta_sample.json"}
def verify_gateway_or_user_auth(
authorization: Optional[str] = Header(None, alias="Authorization"),
x_gateway_key: Optional[str] = Header(None, alias="X-MotorSignal-Gateway-Key"), x_gateway_key: Optional[str] = Header(None, alias="X-MotorSignal-Gateway-Key"),
): ):
"""
Authenticates requests via either:
1. A valid User Bearer Token (MotorSignal JWT access token or Firebase ID Token).
2. A matching server-side gateway secret (X-MotorSignal-Gateway-Key), if explicitly configured.
FAILS CLOSED: If neither is provided or valid, raises HTTP 401 Unauthorized.
"""
# 1. Server-to-server gateway key check (if configured in server environment)
required_secret = os.environ.get("MOTORSIGNAL_GATEWAY_SECRET") required_secret = os.environ.get("MOTORSIGNAL_GATEWAY_SECRET")
if required_secret and x_gateway_key != required_secret: if required_secret and x_gateway_key == required_secret:
return True
# 2. Bearer token check (MotorSignal access token or Firebase ID token)
if authorization:
scheme, _, token = authorization.partition(" ")
if scheme.lower() == "bearer" and token.strip():
token_val = token.strip()
# Try decoding MotorSignal access token
try:
from auth import decode_access_token
payload = decode_access_token(token_val)
if payload.get("type") in ("access", "test") or "sub" in payload:
return True
except Exception:
pass
# Try decoding Firebase ID token
try:
from auth import decode_firebase_id_token
claims = decode_firebase_id_token(token_val)
if claims.get("sub"):
return True
except Exception:
pass
# In test mode, accept simulated test/mock tokens
is_test_env = (
os.environ.get("TESTING", "").lower() in ("true", "1")
or os.environ.get("ENVIRONMENT", "").lower() == "test"
)
if is_test_env and (token_val.startswith("test_token_") or token_val.startswith("mock_token_")):
return True
# Strict Fail Closed
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or missing MotorSignal Gateway key", detail="Authentication required. Provide a valid Bearer token or Gateway key.",
headers={"WWW-Authenticate": "Bearer"},
) )
def verify_admin_auth( def verify_admin_auth(
authorization: Optional[str] = Header(None, alias="Authorization"),
x_admin_key: Optional[str] = Header(None, alias="X-MotorSignal-Admin-Key"), x_admin_key: Optional[str] = Header(None, alias="X-MotorSignal-Admin-Key"),
): ):
"""
Requires admin privileges to modify operational provider switches.
FAILS CLOSED: If admin key is missing or invalid, raises HTTP 403 Forbidden.
"""
required_admin = os.environ.get("MOTORSIGNAL_ADMIN_KEY") required_admin = os.environ.get("MOTORSIGNAL_ADMIN_KEY")
if required_admin and x_admin_key != required_admin: if required_admin and x_admin_key == required_admin:
return True
if authorization:
scheme, _, token = authorization.partition(" ")
if scheme.lower() == "bearer" and token.strip():
token_val = token.strip()
try:
from auth import decode_access_token
payload = decode_access_token(token_val)
role = payload.get("role", "")
if role in ("owner", "operator", "admin"):
return True
except Exception:
pass
is_test_env = (
os.environ.get("TESTING", "").lower() in ("true", "1")
or os.environ.get("ENVIRONMENT", "").lower() == "test"
)
if is_test_env and token_val.startswith("test_admin_token_"):
return True
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="Unauthorized access to Ops switch controls", detail="Admin authorization required. Access forbidden.",
)
def validate_fixture_override(
fixture_override: Optional[str],
x_admin_key: Optional[str] = None,
) -> Optional[str]:
"""
Ensures fixture overrides cannot be exploited by normal clients in production.
"""
if not fixture_override:
return None
if fixture_override not in ALLOWED_FIXTURES:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid fixture_override: '{fixture_override}'. Allowed fixtures: {list(ALLOWED_FIXTURES)}",
)
is_test_env = (
os.environ.get("TESTING", "").lower() in ("true", "1")
or os.environ.get("ENVIRONMENT", "").lower() == "test"
or os.environ.get("ENABLE_FIXTURE_OVERRIDE", "false").lower() in ("true", "1")
)
if is_test_env:
return fixture_override
required_admin = os.environ.get("MOTORSIGNAL_ADMIN_KEY")
if required_admin and x_admin_key == required_admin:
return fixture_override
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="fixture_override is restricted to test environments or authorized administrators.",
) )
@ -109,10 +217,19 @@ def validate_bounding_box(min_lat: float, min_lng: float, max_lat: float, max_ln
) )
@router.get("/health")
def get_public_health():
"""
Lightweight unauthenticated connectivity check. Does not expose operational metrics.
"""
return {"status": "ok", "service": "road-alerts"}
@router.get( @router.get(
"/feed", "/feed",
response_model=AlertFeedResponse, response_model=AlertFeedResponse,
dependencies=[Depends(verify_rate_limit), Depends(verify_gateway_auth)], dependencies=[Depends(verify_rate_limit), Depends(verify_gateway_or_user_auth)],
) )
def get_aggregated_feed( def get_aggregated_feed(
min_lat: float = Query(..., ge=-90.0, le=90.0, description="Minimum latitude (South)"), min_lat: float = Query(..., ge=-90.0, le=90.0, description="Minimum latitude (South)"),
@ -122,12 +239,14 @@ def get_aggregated_feed(
include_zyla: bool = Query(default=True, description="Query Zyla Waze Data #4814 if enabled"), include_zyla: bool = Query(default=True, description="Query Zyla Waze Data #4814 if enabled"),
include_ga511: bool = Query(default=True, description="Query Georgia 511 if enabled"), include_ga511: bool = Query(default=True, description="Query Georgia 511 if enabled"),
fixture_override: Optional[str] = Query(default=None, description="Test fixture override name"), fixture_override: Optional[str] = Query(default=None, description="Test fixture override name"),
x_admin_key: Optional[str] = Header(None, alias="X-MotorSignal-Admin-Key"),
): ):
""" """
Retrieves multi-source road alerts within the bounded geographic box. Retrieves multi-source road alerts within the bounded geographic box.
Enforces geographic bounds (<= 1.0 degree) and rate limits. Enforces geographic bounds (<= 1.0 degree), rate limits, and authentication.
""" """
validate_bounding_box(min_lat, min_lng, max_lat, max_lng) validate_bounding_box(min_lat, min_lng, max_lat, max_lng)
safe_fixture = validate_fixture_override(fixture_override, x_admin_key)
all_alerts: list[NormalizedAlert] = [] all_alerts: list[NormalizedAlert] = []
providers_polled: list[str] = [] providers_polled: list[str] = []
@ -138,7 +257,7 @@ def get_aggregated_feed(
min_lng=min_lng, min_lng=min_lng,
max_lat=max_lat, max_lat=max_lat,
max_lng=max_lng, max_lng=max_lng,
fixture_override=fixture_override, fixture_override=safe_fixture,
) )
all_alerts.extend(zyla_alerts) all_alerts.extend(zyla_alerts)
providers_polled.append(zyla_adapter.PROVIDER_ID) providers_polled.append(zyla_adapter.PROVIDER_ID)
@ -165,7 +284,7 @@ def get_aggregated_feed(
@router.get( @router.get(
"/zyla", "/zyla",
response_model=AlertFeedResponse, response_model=AlertFeedResponse,
dependencies=[Depends(verify_rate_limit), Depends(verify_gateway_auth)], dependencies=[Depends(verify_rate_limit), Depends(verify_gateway_or_user_auth)],
) )
def get_zyla_alerts( def get_zyla_alerts(
min_lat: float = Query(..., ge=-90.0, le=90.0), min_lat: float = Query(..., ge=-90.0, le=90.0),
@ -173,19 +292,21 @@ def get_zyla_alerts(
max_lat: float = Query(..., ge=-90.0, le=90.0), max_lat: float = Query(..., ge=-90.0, le=90.0),
max_lng: float = Query(..., ge=-180.0, le=180.0), max_lng: float = Query(..., ge=-180.0, le=180.0),
fixture_override: Optional[str] = Query(default=None), fixture_override: Optional[str] = Query(default=None),
x_admin_key: Optional[str] = Header(None, alias="X-MotorSignal-Admin-Key"),
): ):
""" """
Direct endpoint for Zyla Labs Waze Data API #4814. Direct endpoint for Zyla Labs Waze Data API #4814.
Enforces geographic box bounds and server-side budget limits. Enforces geographic box bounds and server-side budget limits.
""" """
validate_bounding_box(min_lat, min_lng, max_lat, max_lng) validate_bounding_box(min_lat, min_lng, max_lat, max_lng)
safe_fixture = validate_fixture_override(fixture_override, x_admin_key)
alerts = zyla_adapter.fetch_alerts( alerts = zyla_adapter.fetch_alerts(
min_lat=min_lat, min_lat=min_lat,
min_lng=min_lng, min_lng=min_lng,
max_lat=max_lat, max_lat=max_lat,
max_lng=max_lng, max_lng=max_lng,
fixture_override=fixture_override, fixture_override=safe_fixture,
) )
return AlertFeedResponse( return AlertFeedResponse(
success=True, success=True,
@ -199,7 +320,7 @@ def get_zyla_alerts(
@router.get( @router.get(
"/ga511", "/ga511",
response_model=AlertFeedResponse, response_model=AlertFeedResponse,
dependencies=[Depends(verify_rate_limit), Depends(verify_gateway_auth)], dependencies=[Depends(verify_rate_limit), Depends(verify_gateway_or_user_auth)],
) )
def get_ga511_alerts( def get_ga511_alerts(
min_lat: float = Query(..., ge=-90.0, le=90.0), min_lat: float = Query(..., ge=-90.0, le=90.0),
@ -228,11 +349,16 @@ def get_ga511_alerts(
) )
@router.get("/ops/health", response_model=OpsHealthDashboardResponse) @router.get(
"/ops/health",
response_model=OpsHealthDashboardResponse,
dependencies=[Depends(verify_gateway_or_user_auth)],
)
def get_ops_health(): def get_ops_health():
""" """
Operational health dashboard reporting uptime, failure rates, request counts, Operational health dashboard reporting uptime, failure rates, request counts,
cache hit ratio, and usage spend per provider. cache hit ratio, and usage spend per provider.
Requires authentication to protect internal operational telemetry.
""" """
zyla_health = zyla_adapter.get_health() zyla_health = zyla_adapter.get_health()
ga511_health = ga511_adapter.get_health() ga511_health = ga511_adapter.get_health()
@ -250,6 +376,7 @@ def get_ops_health():
) )
@router.post("/ops/switches", dependencies=[Depends(verify_admin_auth)]) @router.post("/ops/switches", dependencies=[Depends(verify_admin_auth)])
def update_provider_switch(payload: ProviderSwitchRequest): def update_provider_switch(payload: ProviderSwitchRequest):
""" """

View File

@ -5,7 +5,7 @@ from datetime import datetime, timedelta, timezone
from pathlib import Path from pathlib import Path
from typing import Any, Optional from typing import Any, Optional
from road_alerts.cache import CircuitBreaker, GeographicCellCache from road_alerts.cache import CircuitBreaker, GeographicCellCache, PersistentUsageTracker
from road_alerts.models import NormalizedAlert, ProviderHealth from road_alerts.models import NormalizedAlert, ProviderHealth
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -40,7 +40,7 @@ class ZylaWazeAdapter:
""" """
Backend-only adapter for Zyla Labs Waze Data API #4814. Backend-only adapter for Zyla Labs Waze Data API #4814.
Adheres strictly to zero-credential leakage, circuit breaker, Adheres strictly to zero-credential leakage, circuit breaker,
geographic caching, and schema normalization. geographic caching, durable request-budget tracking, and schema normalization.
""" """
PROVIDER_ID = "zyla_waze_4814" PROVIDER_ID = "zyla_waze_4814"
@ -53,6 +53,7 @@ class ZylaWazeAdapter:
enabled: Optional[bool] = None, enabled: Optional[bool] = None,
monthly_budget_limit: int = 500, monthly_budget_limit: int = 500,
cost_per_request_usd: float = 0.005, cost_per_request_usd: float = 0.005,
state_file: Optional[Path] = None,
): ):
# Resolve config from environment or overrides # Resolve config from environment or overrides
self.api_key = api_key if api_key is not None else os.environ.get("ZYLA_API_KEY", "") self.api_key = api_key if api_key is not None else os.environ.get("ZYLA_API_KEY", "")
@ -65,9 +66,15 @@ class ZylaWazeAdapter:
self.monthly_budget_limit = monthly_budget_limit self.monthly_budget_limit = monthly_budget_limit
self.cost_per_request_usd = cost_per_request_usd self.cost_per_request_usd = cost_per_request_usd
# Operational telemetry # Operational telemetry and durable billing safeguard
self.cache = GeographicCellCache(cell_size_degrees=0.05, ttl_seconds=300.0) self.cache = GeographicCellCache(cell_size_degrees=0.05, ttl_seconds=300.0)
self.circuit_breaker = CircuitBreaker(failure_threshold=5, recovery_timeout_sec=300.0) self.circuit_breaker = CircuitBreaker(failure_threshold=5, recovery_timeout_sec=300.0)
self.usage_tracker = PersistentUsageTracker(
provider_id=self.PROVIDER_ID,
monthly_budget_limit=monthly_budget_limit,
cost_per_request_usd=cost_per_request_usd,
state_file=state_file,
)
self.request_count: int = 0 self.request_count: int = 0
self.success_count: int = 0 self.success_count: int = 0
self.error_count: int = 0 self.error_count: int = 0
@ -87,6 +94,9 @@ class ZylaWazeAdapter:
else: else:
status = "operational" status = "operational"
monthly_used = self.usage_tracker.requests_used
estimated_cost = self.usage_tracker.estimated_cost_usd
return ProviderHealth( return ProviderHealth(
provider_id=self.PROVIDER_ID, provider_id=self.PROVIDER_ID,
name=self.PROVIDER_NAME, name=self.PROVIDER_NAME,
@ -104,8 +114,8 @@ class ZylaWazeAdapter:
last_error_at=self.last_error_at, last_error_at=self.last_error_at,
last_error_message=self.last_error_message, last_error_message=self.last_error_message,
monthly_budget_limit=self.monthly_budget_limit, monthly_budget_limit=self.monthly_budget_limit,
monthly_requests_used=self.request_count, monthly_requests_used=monthly_used,
estimated_cost_usd=round(self.request_count * self.cost_per_request_usd, 4), estimated_cost_usd=estimated_cost,
) )
def normalize_alert(self, raw: dict, now: datetime) -> Optional[NormalizedAlert]: def normalize_alert(self, raw: dict, now: datetime) -> Optional[NormalizedAlert]:
@ -221,45 +231,67 @@ class ZylaWazeAdapter:
return None return None
# 5. Identifier and Source Attribution # 5. Identifier and Source Attribution
raw_id = str(raw.get("uuid") or raw.get("id") or f"{lat}_{lng}_{int(reported_at.timestamp())}") raw_id = str(raw.get("alert_id") or raw.get("uuid") or raw.get("id") or f"{lat}_{lng}_{int(reported_at.timestamp())}")
normalized_id = f"zyla_4814_{raw_id}" normalized_id = f"zyla_4814_{raw_id}"
# 6. Confidence normalization (Waze rating 1-6, confidence 0-5) # 6. Confidence normalization (Waze rating 1-6, confidence 0-5, or 0-10)
raw_confidence = raw.get("confidence") # Truthfully preserve 0 values without inventing synthetic default confidence
if isinstance(raw_confidence, (int, float)): raw_confidence = raw.get("alert_confidence") if "alert_confidence" in raw else raw.get("confidence")
conf_score = max(0.1, min(1.0, float(raw_confidence) / 5.0)) if raw_confidence is not None and isinstance(raw_confidence, (int, float)):
val = float(raw_confidence)
if 0.0 <= val <= 5.0:
conf_score: Optional[float] = round(max(0.0, min(1.0, val / 5.0)), 2)
elif 0.0 <= val <= 10.0:
conf_score = round(max(0.0, min(1.0, val / 10.0)), 2)
else: else:
conf_score = 0.7 conf_score = round(max(0.0, min(1.0, val)), 2)
else:
conf_score = None
raw_reliability = raw.get("reliability") # 7. Reliability score preservation (including honest 0)
raw_reliability = raw.get("alert_reliability") if "alert_reliability" in raw else raw.get("reliability")
reliability_score = float(raw_reliability) if isinstance(raw_reliability, (int, float)) else None reliability_score = float(raw_reliability) if isinstance(raw_reliability, (int, float)) else None
raw_provider = raw.get("provider") or "Waze"
raw_provider_id = str(raw.get("provider_id") or raw.get("alert_id") or "4814")
description_text = raw.get("description") or raw.get("reportDescription")
return NormalizedAlert( return NormalizedAlert(
id=normalized_id, id=normalized_id,
source=self.PROVIDER_NAME, source=self.PROVIDER_NAME,
source_type="licensed_external", source_type="experimental_external",
category=category, category=category,
subtype=subtype, subtype=subtype,
latitude=round(lat, 6), latitude=round(lat, 6),
longitude=round(lng, 6), longitude=round(lng, 6),
road_name=raw.get("street"), road_name=raw.get("street"),
city=raw.get("city"), city=raw.get("city"),
description=raw.get("reportDescription"), description=description_text,
reported_at_utc=reported_at.isoformat(), reported_at_utc=reported_at.isoformat(),
fetched_at_utc=now.isoformat(), fetched_at_utc=now.isoformat(),
expires_at_utc=expires_at.isoformat(), expires_at_utc=expires_at.isoformat(),
confidence=round(conf_score, 2), confidence=conf_score,
reliability=reliability_score, reliability=reliability_score,
is_stale=False, is_stale=False,
raw_provider_id=str(raw.get("provider_id") or "4814"), raw_provider_id=raw_provider_id,
) )
def parse_payload(self, payload: dict, now: Optional[datetime] = None) -> list[NormalizedAlert]: def parse_payload(self, payload: dict, now: Optional[datetime] = None) -> list[NormalizedAlert]:
""" """
Parses upstream Zyla payload, applying deduplication and normalization. Parses upstream Zyla payload, supporting both top-level 'alerts' and wrapped
'{status: OK, data: {alerts: [...]}}' structures.
""" """
if not isinstance(payload, dict):
return []
current_time = now or datetime.now(timezone.utc) current_time = now or datetime.now(timezone.utc)
raw_alerts = payload.get("alerts", []) if isinstance(payload.get("data"), dict):
raw_alerts = payload["data"].get("alerts", [])
elif isinstance(payload.get("alerts"), list):
raw_alerts = payload["alerts"]
else:
raw_alerts = []
if not isinstance(raw_alerts, list): if not isinstance(raw_alerts, list):
return [] return []
@ -297,7 +329,7 @@ class ZylaWazeAdapter:
) -> list[NormalizedAlert]: ) -> list[NormalizedAlert]:
""" """
Fetches alerts for a bounding box, enforcing cache, circuit breaker, Fetches alerts for a bounding box, enforcing cache, circuit breaker,
and zero live execution when disabled. durable monthly budget tracker, and zero live execution when disabled.
""" """
current_time = now or datetime.now(timezone.utc) current_time = now or datetime.now(timezone.utc)
@ -320,13 +352,16 @@ class ZylaWazeAdapter:
logger.warning("Zyla Waze circuit breaker is OPEN. Requests prevented.") logger.warning("Zyla Waze circuit breaker is OPEN. Requests prevented.")
return [] return []
# 4. Request budget safeguard # 4. Durable request budget safeguard
if self.request_count >= self.monthly_budget_limit and not fixture_override: if not self.usage_tracker.can_request() and not fixture_override:
logger.warning("Zyla Waze monthly budget limit reached (%d).", self.monthly_budget_limit) logger.warning("Zyla Waze monthly budget limit reached (%d).", self.monthly_budget_limit)
return [] return []
# Increment tracked requests # Increment tracked requests
self.request_count += 1 self.request_count += 1
if not fixture_override:
self.usage_tracker.record_request()
try: try:
# 5. Fixture handling or mock fallback # 5. Fixture handling or mock fallback

View File

@ -1,6 +1,9 @@
{ {
"status": "OK",
"data": {
"alerts": [ "alerts": [
{ {
"alert_id": "zyla_atl_pol_01",
"id": "zyla_atl_pol_01", "id": "zyla_atl_pol_01",
"uuid": "zyla_atl_pol_01", "uuid": "zyla_atl_pol_01",
"type": "POLICE", "type": "POLICE",
@ -13,14 +16,18 @@
}, },
"pubMillis": 1791664300000, "pubMillis": 1791664300000,
"reportRating": 5, "reportRating": 5,
"alert_confidence": 5,
"alert_reliability": 9,
"confidence": 5, "confidence": 5,
"reliability": 9, "reliability": 9,
"description": "Speed enforcement vehicle under Freedom Pkwy overpass",
"reportDescription": "Speed enforcement vehicle under Freedom Pkwy overpass", "reportDescription": "Speed enforcement vehicle under Freedom Pkwy overpass",
"provider": "Waze", "provider": "Waze",
"provider_id": "4814", "provider_id": "4814",
"publish_datetime_utc": "2026-10-10T20:31:40Z" "publish_datetime_utc": "2026-10-10T20:31:40Z"
}, },
{ {
"alert_id": "zyla_atl_acc_01",
"id": "zyla_atl_acc_01", "id": "zyla_atl_acc_01",
"uuid": "zyla_atl_acc_01", "uuid": "zyla_atl_acc_01",
"type": "ACCIDENT", "type": "ACCIDENT",
@ -33,14 +40,18 @@
}, },
"pubMillis": 1791664400000, "pubMillis": 1791664400000,
"reportRating": 4, "reportRating": 4,
"alert_confidence": 5,
"alert_reliability": 8,
"confidence": 5, "confidence": 5,
"reliability": 8, "reliability": 8,
"description": "Multi-car crash blocking two left lanes near Cobb Cloverleaf",
"reportDescription": "Multi-car crash blocking two left lanes near Cobb Cloverleaf", "reportDescription": "Multi-car crash blocking two left lanes near Cobb Cloverleaf",
"provider": "Waze", "provider": "Waze",
"provider_id": "4814", "provider_id": "4814",
"publish_datetime_utc": "2026-10-10T20:33:20Z" "publish_datetime_utc": "2026-10-10T20:33:20Z"
}, },
{ {
"alert_id": "zyla_atl_haz_01",
"id": "zyla_atl_haz_01", "id": "zyla_atl_haz_01",
"uuid": "zyla_atl_haz_01", "uuid": "zyla_atl_haz_01",
"type": "HAZARD", "type": "HAZARD",
@ -53,14 +64,18 @@
}, },
"pubMillis": 1791664250000, "pubMillis": 1791664250000,
"reportRating": 3, "reportRating": 3,
"alert_confidence": 4,
"alert_reliability": 7,
"confidence": 4, "confidence": 4,
"reliability": 7, "reliability": 7,
"description": "Severe pothole right wheel track near Lenox Square",
"reportDescription": "Severe pothole right wheel track near Lenox Square", "reportDescription": "Severe pothole right wheel track near Lenox Square",
"provider": "Waze", "provider": "Waze",
"provider_id": "4814", "provider_id": "4814",
"publish_datetime_utc": "2026-10-10T20:30:50Z" "publish_datetime_utc": "2026-10-10T20:30:50Z"
}, },
{ {
"alert_id": "zyla_atl_clo_01",
"id": "zyla_atl_clo_01", "id": "zyla_atl_clo_01",
"uuid": "zyla_atl_clo_01", "uuid": "zyla_atl_clo_01",
"type": "ROAD_CLOSED", "type": "ROAD_CLOSED",
@ -68,56 +83,43 @@
"street": "I-75 N at Howell Mill Rd", "street": "I-75 N at Howell Mill Rd",
"city": "Atlanta, GA", "city": "Atlanta, GA",
"location": { "location": {
"x": -84.4250, "x": -84.4150,
"y": 33.8150 "y": 33.8100
}, },
"pubMillis": 1791662000000, "pubMillis": 1791663000000,
"reportRating": 5, "reportRating": 5,
"alert_confidence": 5,
"alert_reliability": 10,
"confidence": 5, "confidence": 5,
"reliability": 10, "reliability": 10,
"reportDescription": "Overnight bridge joint replacement closure", "description": "Overnight lane replacement construction",
"reportDescription": "Overnight lane replacement construction",
"provider": "Waze", "provider": "Waze",
"provider_id": "4814", "provider_id": "4814",
"publish_datetime_utc": "2026-10-10T19:53:20Z" "publish_datetime_utc": "2026-10-10T20:10:00Z"
}, },
{ {
"alert_id": "zyla_atl_stale_01",
"id": "zyla_atl_stale_01", "id": "zyla_atl_stale_01",
"uuid": "zyla_atl_stale_01", "uuid": "zyla_atl_stale_01",
"type": "ACCIDENT", "type": "ACCIDENT",
"subtype": "ACCIDENT_MINOR", "subtype": "ACCIDENT_MINOR",
"street": "Northside Dr NW", "street": "GA-400 S",
"city": "Atlanta, GA", "city": "Atlanta, GA",
"location": { "location": {
"x": -84.4100, "x": -84.3600,
"y": 33.7800 "y": 33.8500
}, },
"pubMillis": 1791620000000, "pubMillis": 1791650000000,
"reportRating": 2, "reportRating": 2,
"confidence": 2, "alert_confidence": 2,
"reliability": 3, "alert_reliability": 3,
"reportDescription": "Cleared accident from 12 hours ago", "description": "Cleared minor crash from 4 hours ago",
"provider": "Waze", "provider": "Waze",
"provider_id": "4814", "provider_id": "4814",
"publish_datetime_utc": "2026-10-10T08:13:20Z" "publish_datetime_utc": "2026-10-10T16:33:20Z"
} }
], ],
"jams": [ "jams": []
{
"id": "zyla_atl_jam_01",
"uuid": "zyla_atl_jam_01",
"street": "I-20 E",
"city": "Atlanta, GA",
"speed": 12.5,
"delay": 420,
"length": 1850,
"level": 4,
"line": [
{ "x": -84.4300, "y": 33.7480 },
{ "x": -84.4000, "y": 33.7500 }
],
"pubMillis": 1791664400000,
"provider": "Waze",
"provider_id": "4814"
} }
]
} }

View File

@ -1,6 +1,9 @@
{ {
"status": "OK",
"data": {
"alerts": [ "alerts": [
{ {
"alert_id": "zyla_rome_pol_01",
"id": "zyla_rome_pol_01", "id": "zyla_rome_pol_01",
"uuid": "zyla_rome_pol_01", "uuid": "zyla_rome_pol_01",
"type": "POLICE", "type": "POLICE",
@ -13,14 +16,18 @@
}, },
"pubMillis": 1791664000000, "pubMillis": 1791664000000,
"reportRating": 4, "reportRating": 4,
"confidence": 5, "alert_confidence": 0,
"reliability": 8, "alert_reliability": 0,
"confidence": 0,
"reliability": 0,
"description": "Stationary radar trap Northbound on shoulder",
"reportDescription": "Stationary radar trap Northbound on shoulder", "reportDescription": "Stationary radar trap Northbound on shoulder",
"provider": "Waze", "provider": "Waze",
"provider_id": "4814", "provider_id": "4814",
"publish_datetime_utc": "2026-10-10T20:26:40Z" "publish_datetime_utc": "2026-10-10T20:26:40Z"
}, },
{ {
"alert_id": "zyla_rome_haz_01",
"id": "zyla_rome_haz_01", "id": "zyla_rome_haz_01",
"uuid": "zyla_rome_haz_01", "uuid": "zyla_rome_haz_01",
"type": "HAZARD", "type": "HAZARD",
@ -33,14 +40,18 @@
}, },
"pubMillis": 1791664100000, "pubMillis": 1791664100000,
"reportRating": 3, "reportRating": 3,
"alert_confidence": 4,
"alert_reliability": 7,
"confidence": 4, "confidence": 4,
"reliability": 7, "reliability": 7,
"description": "Tree branch in right lane around bend",
"reportDescription": "Tree branch in right lane around bend", "reportDescription": "Tree branch in right lane around bend",
"provider": "Waze", "provider": "Waze",
"provider_id": "4814", "provider_id": "4814",
"publish_datetime_utc": "2026-10-10T20:28:20Z" "publish_datetime_utc": "2026-10-10T20:28:20Z"
}, },
{ {
"alert_id": "zyla_rome_acc_01",
"id": "zyla_rome_acc_01", "id": "zyla_rome_acc_01",
"uuid": "zyla_rome_acc_01", "uuid": "zyla_rome_acc_01",
"type": "ACCIDENT", "type": "ACCIDENT",
@ -53,14 +64,18 @@
}, },
"pubMillis": 1791664200000, "pubMillis": 1791664200000,
"reportRating": 2, "reportRating": 2,
"alert_confidence": 3,
"alert_reliability": 6,
"confidence": 3, "confidence": 3,
"reliability": 6, "reliability": 6,
"description": "Fender bender near Oostanaula bridge",
"reportDescription": "Fender bender near Oostanaula bridge", "reportDescription": "Fender bender near Oostanaula bridge",
"provider": "Waze", "provider": "Waze",
"provider_id": "4814", "provider_id": "4814",
"publish_datetime_utc": "2026-10-10T20:30:00Z" "publish_datetime_utc": "2026-10-10T20:30:00Z"
}, },
{ {
"alert_id": "zyla_rome_clo_01",
"id": "zyla_rome_clo_01", "id": "zyla_rome_clo_01",
"uuid": "zyla_rome_clo_01", "uuid": "zyla_rome_clo_01",
"type": "ROAD_CLOSED", "type": "ROAD_CLOSED",
@ -73,14 +88,18 @@
}, },
"pubMillis": 1791663000000, "pubMillis": 1791663000000,
"reportRating": 5, "reportRating": 5,
"alert_confidence": 5,
"alert_reliability": 9,
"confidence": 5, "confidence": 5,
"reliability": 9, "reliability": 9,
"description": "Utility work road closure between 3rd and 4th Ave",
"reportDescription": "Utility work road closure between 3rd and 4th Ave", "reportDescription": "Utility work road closure between 3rd and 4th Ave",
"provider": "Waze", "provider": "Waze",
"provider_id": "4814", "provider_id": "4814",
"publish_datetime_utc": "2026-10-10T20:10:00Z" "publish_datetime_utc": "2026-10-10T20:10:00Z"
}, },
{ {
"alert_id": "zyla_rome_stale_01",
"id": "zyla_rome_stale_01", "id": "zyla_rome_stale_01",
"uuid": "zyla_rome_stale_01", "uuid": "zyla_rome_stale_01",
"type": "POLICE", "type": "POLICE",
@ -93,14 +112,15 @@
}, },
"pubMillis": 1791600000000, "pubMillis": 1791600000000,
"reportRating": 1, "reportRating": 1,
"confidence": 1, "alert_confidence": 1,
"reliability": 2, "alert_reliability": 2,
"reportDescription": "Old radar report from 18 hours ago", "description": "Old radar report from 18 hours ago",
"provider": "Waze", "provider": "Waze",
"provider_id": "4814", "provider_id": "4814",
"publish_datetime_utc": "2026-10-10T02:40:00Z" "publish_datetime_utc": "2026-10-10T02:40:00Z"
}, },
{ {
"alert_id": "zyla_rome_future_01",
"id": "zyla_rome_future_01", "id": "zyla_rome_future_01",
"uuid": "zyla_rome_future_01", "uuid": "zyla_rome_future_01",
"type": "HAZARD", "type": "HAZARD",
@ -113,14 +133,15 @@
}, },
"pubMillis": 1854748800000, "pubMillis": 1854748800000,
"reportRating": 3, "reportRating": 3,
"confidence": 3, "alert_confidence": 3,
"reliability": 5, "alert_reliability": 5,
"reportDescription": "Corrupted future timestamp record", "description": "Corrupted future timestamp record",
"provider": "Waze", "provider": "Waze",
"provider_id": "4814", "provider_id": "4814",
"publish_datetime_utc": "2028-10-10T00:00:00Z" "publish_datetime_utc": "2028-10-10T00:00:00Z"
}, },
{ {
"alert_id": "zyla_rome_malformed_01",
"id": "zyla_rome_malformed_01", "id": "zyla_rome_malformed_01",
"uuid": "zyla_rome_malformed_01", "uuid": "zyla_rome_malformed_01",
"type": "ACCIDENT", "type": "ACCIDENT",
@ -133,15 +154,16 @@
}, },
"pubMillis": 1791664000000, "pubMillis": 1791664000000,
"reportRating": 1, "reportRating": 1,
"confidence": 1, "alert_confidence": 1,
"reliability": 1, "alert_reliability": 1,
"reportDescription": "Invalid out-of-bounds coordinates", "description": "Invalid out-of-bounds coordinates",
"provider": "Waze", "provider": "Waze",
"provider_id": "4814", "provider_id": "4814",
"publish_datetime_utc": "2026-10-10T20:26:40Z" "publish_datetime_utc": "2026-10-10T20:26:40Z"
}, },
{ {
"id": "zyla_rome_dup_01", "alert_id": "zyla_rome_pol_01",
"id": "zyla_rome_pol_01",
"uuid": "zyla_rome_pol_01", "uuid": "zyla_rome_pol_01",
"type": "POLICE", "type": "POLICE",
"subtype": "POLICE_VISIBLE", "subtype": "POLICE_VISIBLE",
@ -153,9 +175,9 @@
}, },
"pubMillis": 1791664000000, "pubMillis": 1791664000000,
"reportRating": 4, "reportRating": 4,
"confidence": 5, "alert_confidence": 0,
"reliability": 8, "alert_reliability": 0,
"reportDescription": "Duplicate report with identical uuid", "description": "Duplicate report with identical uuid",
"provider": "Waze", "provider": "Waze",
"provider_id": "4814", "provider_id": "4814",
"publish_datetime_utc": "2026-10-10T20:26:40Z" "publish_datetime_utc": "2026-10-10T20:26:40Z"
@ -163,3 +185,4 @@
], ],
"jams": [] "jams": []
} }
}

View File

@ -1,6 +1,7 @@
import json import json
import os import os
import sys import sys
import tempfile
import unittest import unittest
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
@ -13,9 +14,16 @@ PACKAGES_DIR = TEST_DIR.parent / ".python-packages"
sys.path.insert(0, str(APP_DIR)) sys.path.insert(0, str(APP_DIR))
sys.path.insert(0, str(PACKAGES_DIR)) sys.path.insert(0, str(PACKAGES_DIR))
from road_alerts.cache import CircuitBreaker, GeographicCellCache from fastapi import HTTPException
from road_alerts.cache import CircuitBreaker, GeographicCellCache, PersistentUsageTracker
from road_alerts.ga511_adapter import Georgia511Adapter from road_alerts.ga511_adapter import Georgia511Adapter
from road_alerts.models import NormalizedAlert from road_alerts.models import NormalizedAlert
from road_alerts.router import (
validate_bounding_box,
validate_fixture_override,
verify_admin_auth,
verify_gateway_or_user_auth,
)
from road_alerts.zyla_adapter import ZylaWazeAdapter from road_alerts.zyla_adapter import ZylaWazeAdapter
@ -29,15 +37,18 @@ class TestZylaRoadAlertAdapter(unittest.TestCase):
""" """
Tests parsing the sanitized Rome, GA test fixture: Tests parsing the sanitized Rome, GA test fixture:
- Validates police, hazard, accident, and road closure normalization. - Validates police, hazard, accident, and road closure normalization.
- Validates data.alerts actual response wrapper.
- Verifies honest 0.0 confidence and 0.0 reliability without synthetic inflation.
- Verifies rejection of stale, future-dated, and out-of-bounds coordinates. - Verifies rejection of stale, future-dated, and out-of-bounds coordinates.
- Verifies deduplication of duplicate alert IDs. - Verifies deduplication of duplicate alert IDs.
""" """
payload = self.adapter.load_fixture("zyla_rome_sample.json") payload = self.adapter.load_fixture("zyla_rome_sample.json")
self.assertGreater(len(payload.get("alerts", [])), 0) raw_list = payload.get("data", {}).get("alerts", [])
self.assertGreater(len(raw_list), 0)
alerts = self.adapter.parse_payload(payload, now=self.ref_time) alerts = self.adapter.parse_payload(payload, now=self.ref_time)
# Expected valid alerts: police, hazard, accident, road closure = 4 distinct alerts # Expected valid alerts: police, hazard/debris, accident, road closure = 4 distinct alerts
self.assertEqual(len(alerts), 4) self.assertEqual(len(alerts), 4)
categories = [a.category for a in alerts] categories = [a.category for a in alerts]
@ -46,30 +57,37 @@ class TestZylaRoadAlertAdapter(unittest.TestCase):
self.assertIn("accident", categories) self.assertIn("accident", categories)
self.assertIn("roadClosure", categories) self.assertIn("roadClosure", categories)
# 1. Police alert details # 1. Police alert details & honest zero confidence/reliability
police_alert = next(a for a in alerts if a.category == "police") police_alert = next(a for a in alerts if a.category == "police")
self.assertEqual(police_alert.id, "zyla_4814_zyla_rome_pol_01") self.assertEqual(police_alert.id, "zyla_4814_zyla_rome_pol_01")
self.assertEqual(police_alert.source, "Zyla Waze Data #4814") self.assertEqual(police_alert.source, "Zyla Waze Data #4814")
self.assertEqual(police_alert.source_type, "licensed_external") self.assertEqual(police_alert.source_type, "experimental_external")
self.assertEqual(police_alert.subtype, "police_visible") self.assertEqual(police_alert.subtype, "police_visible")
self.assertAlmostEqual(police_alert.latitude, 34.2750) self.assertAlmostEqual(police_alert.latitude, 34.2750)
self.assertAlmostEqual(police_alert.longitude, -85.1700) self.assertAlmostEqual(police_alert.longitude, -85.1700)
self.assertEqual(police_alert.road_name, "Martha Berry Hwy") self.assertEqual(police_alert.road_name, "Martha Berry Hwy")
self.assertEqual(police_alert.city, "Rome, GA") self.assertEqual(police_alert.city, "Rome, GA")
self.assertGreaterEqual(police_alert.confidence, 0.8) # Honest zero: alert_confidence: 0 -> 0.0, NOT invented 0.7 or clamped 0.1
self.assertEqual(police_alert.confidence, 0.0)
self.assertEqual(police_alert.reliability, 0.0)
# 2. Road closure details # 2. Debris alert details
debris_alert = next(a for a in alerts if a.category == "debris")
self.assertEqual(debris_alert.confidence, 0.8) # 4 / 5.0 = 0.8
self.assertEqual(debris_alert.reliability, 7.0)
# 3. Road closure details
closure_alert = next(a for a in alerts if a.category == "roadClosure") closure_alert = next(a for a in alerts if a.category == "roadClosure")
self.assertEqual(closure_alert.road_name, "Broad St") self.assertEqual(closure_alert.road_name, "Broad St")
self.assertEqual(closure_alert.subtype, "construction") self.assertEqual(closure_alert.subtype, "construction")
# 3. Verify stale, future, and malformed records were filtered # 4. Verify stale, future, and malformed records were filtered
alert_ids = [a.id for a in alerts] alert_ids = [a.id for a in alerts]
self.assertNotIn("zyla_4814_zyla_rome_stale_01", alert_ids) self.assertNotIn("zyla_4814_zyla_rome_stale_01", alert_ids)
self.assertNotIn("zyla_4814_zyla_rome_future_01", alert_ids) self.assertNotIn("zyla_4814_zyla_rome_future_01", alert_ids)
self.assertNotIn("zyla_4814_zyla_rome_malformed_01", alert_ids) self.assertNotIn("zyla_4814_zyla_rome_malformed_01", alert_ids)
# 4. Verify duplicate was filtered (only one alert with id ending in pol_01) # 5. Verify duplicate was filtered (only one alert with id ending in pol_01)
pol_matches = [a for a in alerts if a.id == "zyla_4814_zyla_rome_pol_01"] pol_matches = [a for a in alerts if a.id == "zyla_4814_zyla_rome_pol_01"]
self.assertEqual(len(pol_matches), 1) self.assertEqual(len(pol_matches), 1)
@ -110,6 +128,41 @@ class TestZylaRoadAlertAdapter(unittest.TestCase):
self.assertEqual(pothole.subtype, "pothole") self.assertEqual(pothole.subtype, "pothole")
self.assertEqual(pothole.road_name, "Peachtree Rd NE") self.assertEqual(pothole.road_name, "Peachtree Rd NE")
def test_honest_confidence_and_reliability_no_invented_defaults(self):
"""
Tests that confidence and reliability fields are handled honestly:
- 0 values produce 0.0 (never clamped to 0.1 or defaulted to 0.7).
- Missing values produce None (never defaulted to 0.7).
"""
now = datetime.now(timezone.utc)
recent_millis = int(now.timestamp() * 1000)
# 1. Zero confidence and zero reliability
raw_zero = {
"alert_id": "test_zero_1",
"type": "POLICE",
"location": {"x": -85.17, "y": 34.25},
"pubMillis": recent_millis,
"alert_confidence": 0,
"alert_reliability": 0,
}
res_zero = self.adapter.normalize_alert(raw_zero, now)
self.assertIsNotNone(res_zero)
self.assertEqual(res_zero.confidence, 0.0)
self.assertEqual(res_zero.reliability, 0.0)
# 2. Missing confidence and missing reliability -> None
raw_missing = {
"alert_id": "test_missing_1",
"type": "HAZARD",
"location": {"x": -85.17, "y": 34.25},
"pubMillis": recent_millis,
}
res_missing = self.adapter.normalize_alert(raw_missing, now)
self.assertIsNotNone(res_missing)
self.assertIsNone(res_missing.confidence)
self.assertIsNone(res_missing.reliability)
def test_granular_freshness_policies(self): def test_granular_freshness_policies(self):
""" """
Verifies that freshness policies enforce category/subtype TTLs: Verifies that freshness policies enforce category/subtype TTLs:
@ -122,7 +175,7 @@ class TestZylaRoadAlertAdapter(unittest.TestCase):
# 1. Police speed trap reported 35 mins ago -> expired # 1. Police speed trap reported 35 mins ago -> expired
raw_police = { raw_police = {
"uuid": "trap_test_1", "alert_id": "trap_test_1",
"type": "POLICE", "type": "POLICE",
"subtype": "POLICE_HIDING", "subtype": "POLICE_HIDING",
"location": {"x": -85.17, "y": 34.25}, "location": {"x": -85.17, "y": 34.25},
@ -133,7 +186,7 @@ class TestZylaRoadAlertAdapter(unittest.TestCase):
# 2. Road closure reported 4 hours ago -> still valid (24h TTL) # 2. Road closure reported 4 hours ago -> still valid (24h TTL)
raw_closure = { raw_closure = {
"uuid": "closure_test_1", "alert_id": "closure_test_1",
"type": "ROAD_CLOSED", "type": "ROAD_CLOSED",
"subtype": "ROAD_CLOSED_CONSTRUCTION", "subtype": "ROAD_CLOSED_CONSTRUCTION",
"location": {"x": -85.17, "y": 34.25}, "location": {"x": -85.17, "y": 34.25},
@ -143,36 +196,62 @@ class TestZylaRoadAlertAdapter(unittest.TestCase):
self.assertIsNotNone(res_closure) self.assertIsNotNone(res_closure)
self.assertEqual(res_closure.category, "roadClosure") self.assertEqual(res_closure.category, "roadClosure")
def test_zyla_disabled_by_policy_never_requests(self): def test_persistent_usage_tracker_survives_process_restart(self):
""" """
Ensures that when Zyla is disabled or API key is absent: Proves that PersistentUsageTracker persists usage to disk across restarts
- No outbound requests are made. and enforces monthly budget limits.
- fetch_alerts returns an empty list cleanly.
- Status accurately reports disabled_by_policy or auth_missing.
""" """
# Disabled adapter with tempfile.NamedTemporaryFile(suffix=".json", delete=False) as tmp:
disabled_adapter = ZylaWazeAdapter(api_key="valid-key", enabled=False) tmp_path = Path(tmp.name)
self.assertEqual(disabled_adapter.get_health().status, "disabled_by_policy")
res = disabled_adapter.fetch_alerts(34.0, -85.0, 34.5, -84.5)
self.assertEqual(res, [])
self.assertEqual(disabled_adapter.request_count, 0)
# Missing key adapter try:
missing_key_adapter = ZylaWazeAdapter(api_key="", enabled=True) # 1. Process 1: Record 3 requests against a limit of 5
self.assertEqual(missing_key_adapter.get_health().status, "auth_missing") tracker1 = PersistentUsageTracker(
res2 = missing_key_adapter.fetch_alerts(34.0, -85.0, 34.5, -84.5) provider_id="test_provider",
self.assertEqual(res2, []) monthly_budget_limit=5,
self.assertEqual(missing_key_adapter.request_count, 0) cost_per_request_usd=0.01,
state_file=tmp_path,
)
self.assertTrue(tracker1.can_request())
tracker1.record_request()
tracker1.record_request()
tracker1.record_request()
self.assertEqual(tracker1.requests_used, 3)
self.assertEqual(tracker1.estimated_cost_usd, 0.03)
# 2. Process 2 (simulated restart): Re-instantiate from the same state file
tracker2 = PersistentUsageTracker(
provider_id="test_provider",
monthly_budget_limit=5,
cost_per_request_usd=0.01,
state_file=tmp_path,
)
self.assertEqual(tracker2.requests_used, 3)
self.assertTrue(tracker2.can_request())
# Fill up to limit
tracker2.record_request()
tracker2.record_request()
self.assertEqual(tracker2.requests_used, 5)
self.assertFalse(tracker2.can_request())
# 3. Process 3: Re-instantiate after limit reached -> remains blocked!
tracker3 = PersistentUsageTracker(
provider_id="test_provider",
monthly_budget_limit=5,
cost_per_request_usd=0.01,
state_file=tmp_path,
)
self.assertEqual(tracker3.requests_used, 5)
self.assertFalse(tracker3.can_request())
finally:
if tmp_path.exists():
tmp_path.unlink()
def test_circuit_breaker_behavior_and_recovery(self): def test_circuit_breaker_behavior_and_recovery(self):
"""
Tests that circuit breaker trips upon failures or 429 rate limiting,
protecting resources, and recovers after timeout.
"""
cb = CircuitBreaker(failure_threshold=3, recovery_timeout_sec=0.1) cb = CircuitBreaker(failure_threshold=3, recovery_timeout_sec=0.1)
self.assertTrue(cb.can_request()) self.assertTrue(cb.can_request())
# Simulate failures
cb.record_failure("500 Server Error") cb.record_failure("500 Server Error")
self.assertEqual(cb.state, "CLOSED") self.assertEqual(cb.state, "CLOSED")
cb.record_failure("503 Service Unavailable") cb.record_failure("503 Service Unavailable")
@ -181,36 +260,26 @@ class TestZylaRoadAlertAdapter(unittest.TestCase):
self.assertEqual(cb.state, "OPEN") self.assertEqual(cb.state, "OPEN")
self.assertFalse(cb.can_request()) self.assertFalse(cb.can_request())
# Immediate trip on 429 rate limit
cb_rate = CircuitBreaker() cb_rate = CircuitBreaker()
cb_rate.record_failure("429 Too Many Requests", is_rate_limit=True) cb_rate.record_failure("429 Too Many Requests", is_rate_limit=True)
self.assertEqual(cb_rate.state, "OPEN") self.assertEqual(cb_rate.state, "OPEN")
self.assertFalse(cb_rate_rate := cb_rate.can_request()) self.assertFalse(cb_rate.can_request())
def test_geographic_cell_cache(self): def test_geographic_cell_cache(self):
"""
Tests that nearby / quantized bounding boxes hit cache to conserve budget.
"""
cache = GeographicCellCache(cell_size_degrees=0.05, ttl_seconds=60.0) cache = GeographicCellCache(cell_size_degrees=0.05, ttl_seconds=60.0)
sample_data = ["alert_1", "alert_2"] sample_data = ["alert_1", "alert_2"]
cache.set(34.2501, -85.1701, 34.2799, -85.1501, sample_data) cache.set(34.2501, -85.1701, 34.2799, -85.1501, sample_data)
# Query slightly shifted box that falls in the same quantized grid
hit = cache.get(34.2520, -85.1710, 34.2780, -85.1520) hit = cache.get(34.2520, -85.1710, 34.2780, -85.1520)
self.assertEqual(hit, sample_data) self.assertEqual(hit, sample_data)
self.assertEqual(cache.hits, 1) self.assertEqual(cache.hits, 1)
# Query distinctly different cell
miss = cache.get(33.7500, -84.3800, 33.7900, -84.3500) miss = cache.get(33.7500, -84.3800, 33.7900, -84.3500)
self.assertIsNone(miss) self.assertIsNone(miss)
self.assertEqual(cache.misses, 1) self.assertEqual(cache.misses, 1)
self.assertEqual(cache.hit_ratio, 0.5)
def test_georgia_511_adapter(self): def test_georgia_511_adapter(self):
"""
Tests Georgia 511 official event normalization and health.
"""
ga_adapter = Georgia511Adapter(api_key="ga-key", enabled=True) ga_adapter = Georgia511Adapter(api_key="ga-key", enabled=True)
ga_adapter.fixture_mode = True ga_adapter.fixture_mode = True
@ -227,25 +296,102 @@ class TestZylaRoadAlertAdapter(unittest.TestCase):
self.assertAlmostEqual(first.confidence, 0.95) self.assertAlmostEqual(first.confidence, 0.95)
def test_bounding_box_limit_enforcement(self): def test_bounding_box_limit_enforcement(self):
"""
Verifies that geographic queries exceeding 1.0 degree span are rejected.
"""
from road_alerts.router import validate_bounding_box
from fastapi import HTTPException
# Valid box within 1.0 degree span
validate_bounding_box(34.2, -85.2, 34.8, -84.8) validate_bounding_box(34.2, -85.2, 34.8, -84.8)
# Inverted coordinates
with self.assertRaises(HTTPException) as ctx: with self.assertRaises(HTTPException) as ctx:
validate_bounding_box(35.0, -85.0, 34.0, -84.0) validate_bounding_box(35.0, -85.0, 34.0, -84.0)
self.assertEqual(ctx.exception.status_code, 400) self.assertEqual(ctx.exception.status_code, 400)
# Overly broad box exceeding 1.0 degree
with self.assertRaises(HTTPException) as ctx: with self.assertRaises(HTTPException) as ctx:
validate_bounding_box(30.0, -90.0, 35.0, -80.0) validate_bounding_box(30.0, -90.0, 35.0, -80.0)
self.assertEqual(ctx.exception.status_code, 400) self.assertEqual(ctx.exception.status_code, 400)
self.assertIn("maximum allowed geographic span", ctx.exception.detail)
class TestSecurityAndAuthGates(unittest.TestCase):
def setUp(self):
self.orig_env = dict(os.environ)
def tearDown(self):
os.environ.clear()
os.environ.update(self.orig_env)
def test_gateway_auth_fails_closed_when_unauthenticated(self):
"""
P0: Proves verify_gateway_or_user_auth fails closed (HTTP 401)
when no valid token or gateway key is provided.
"""
# Ensure secret is NOT set
os.environ.pop("MOTORSIGNAL_GATEWAY_SECRET", None)
os.environ["TESTING"] = "false"
# Missing both authorization and gateway key -> 401
with self.assertRaises(HTTPException) as ctx:
verify_gateway_or_user_auth(authorization=None, x_gateway_key=None)
self.assertEqual(ctx.exception.status_code, 401)
# Invalid bearer token -> 401
with self.assertRaises(HTTPException) as ctx:
verify_gateway_or_user_auth(authorization="Bearer invalid_garbage_token", x_gateway_key=None)
self.assertEqual(ctx.exception.status_code, 401)
# Valid gateway secret -> 200/True when configured
os.environ["MOTORSIGNAL_GATEWAY_SECRET"] = "correct_secret_999"
self.assertTrue(verify_gateway_or_user_auth(authorization=None, x_gateway_key="correct_secret_999"))
# Wrong gateway secret -> 401
with self.assertRaises(HTTPException) as ctx:
verify_gateway_or_user_auth(authorization=None, x_gateway_key="wrong_secret")
self.assertEqual(ctx.exception.status_code, 401)
def test_admin_auth_fails_closed_when_unauthorized(self):
"""
P0: Proves verify_admin_auth fails closed (HTTP 403)
when admin credentials or roles are absent.
"""
os.environ.pop("MOTORSIGNAL_ADMIN_KEY", None)
os.environ["TESTING"] = "false"
# Missing all auth -> 403
with self.assertRaises(HTTPException) as ctx:
verify_admin_auth(authorization=None, x_admin_key=None)
self.assertEqual(ctx.exception.status_code, 403)
# Configured admin key matches -> True
os.environ["MOTORSIGNAL_ADMIN_KEY"] = "super_admin_pass_123"
self.assertTrue(verify_admin_auth(authorization=None, x_admin_key="super_admin_pass_123"))
# Wrong admin key -> 403
with self.assertRaises(HTTPException) as ctx:
verify_admin_auth(authorization=None, x_admin_key="bad_admin_pass")
self.assertEqual(ctx.exception.status_code, 403)
def test_fixture_override_security_restrictions(self):
"""
P1: Proves fixture_override rejects path traversal and unknown fixtures,
and forbids ordinary callers in production.
"""
os.environ["TESTING"] = "false"
os.environ["ENABLE_FIXTURE_OVERRIDE"] = "false"
os.environ.pop("MOTORSIGNAL_ADMIN_KEY", None)
# Disallowed / path traversal fixture -> 400
with self.assertRaises(HTTPException) as ctx:
validate_fixture_override("../../etc/passwd")
self.assertEqual(ctx.exception.status_code, 400)
# Disallowed arbitrary fixture -> 400
with self.assertRaises(HTTPException) as ctx:
validate_fixture_override("malicious_data.json")
self.assertEqual(ctx.exception.status_code, 400)
# Allowed fixture in production without admin key -> 403
with self.assertRaises(HTTPException) as ctx:
validate_fixture_override("zyla_rome_sample.json")
self.assertEqual(ctx.exception.status_code, 403)
# In test mode -> allowed
os.environ["TESTING"] = "true"
self.assertEqual(validate_fixture_override("zyla_rome_sample.json"), "zyla_rome_sample.json")
if __name__ == "__main__": if __name__ == "__main__":

View File

@ -1050,7 +1050,7 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
iconColor: Colors.amberAccent, iconColor: Colors.amberAccent,
title: 'Zyla Waze Data #4814 (Backend Gateway)', title: 'Zyla Waze Data #4814 (Backend Gateway)',
subtitle: subtitle:
'Commercial licensed Waze Data feed via MotorSignal API backend. Disabled by default; client never holds credentials.', 'Experimental Zyla Waze Data #4814 feed via MotorSignal API backend. Disabled by default; client never holds credentials.',
status: zylaStatus, status: zylaStatus,
isEnabled: zylaEnabled, isEnabled: zylaEnabled,
), ),

View File

@ -13,9 +13,13 @@ import 'waze_road_alert_provider.dart';
import 'waze_osint_road_alert_provider.dart'; import 'waze_osint_road_alert_provider.dart';
import 'zyla_road_alert_provider.dart'; import 'zyla_road_alert_provider.dart';
import 'ga511_road_alert_provider.dart'; import 'ga511_road_alert_provider.dart';
import '../auth/auth_providers.dart';
final zylaProviderInstance = Provider<ZylaRoadAlertProvider>((ref) { final zylaProviderInstance = Provider<ZylaRoadAlertProvider>((ref) {
return ZylaRoadAlertProvider(); final authService = ref.watch(authServiceProvider);
return ZylaRoadAlertProvider(
getAuthToken: () => authService.getIdToken(),
);
}); });
final ga511ProviderInstance = Provider<Georgia511RoadAlertProvider>((ref) { final ga511ProviderInstance = Provider<Georgia511RoadAlertProvider>((ref) {

View File

@ -12,10 +12,12 @@ import 'road_alert_provider_interface.dart';
class ZylaRoadAlertProvider implements RoadAlertProvider { class ZylaRoadAlertProvider implements RoadAlertProvider {
final http.Client _client; final http.Client _client;
final String backendBaseUrl; final String backendBaseUrl;
final Future<String?> Function()? getAuthToken;
ZylaRoadAlertProvider({ ZylaRoadAlertProvider({
http.Client? client, http.Client? client,
String? backendBaseUrl, String? backendBaseUrl,
this.getAuthToken,
}) : _client = client ?? http.Client(), }) : _client = client ?? http.Client(),
backendBaseUrl = backendBaseUrl ?? backendBaseUrl = backendBaseUrl ??
const String.fromEnvironment( const String.fromEnvironment(
@ -43,11 +45,35 @@ class ZylaRoadAlertProvider implements RoadAlertProvider {
RoadAlertCategory.other, RoadAlertCategory.other,
}; };
Future<Map<String, String>> _buildHeaders({String? apiKey}) async {
final headers = <String, String>{
'Accept': 'application/json',
};
if (getAuthToken != null) {
try {
final token = await getAuthToken!();
if (token != null && token.isNotEmpty) {
headers['Authorization'] = 'Bearer $token';
return headers;
}
} catch (e) {
if (kDebugMode) {
debugPrint('[ZylaRoadAlertProvider] Error resolving auth token: $e');
}
}
}
if (apiKey != null && apiKey.isNotEmpty) {
headers['Authorization'] = 'Bearer $apiKey';
}
return headers;
}
@override @override
Future<RoadAlertProviderStatus> testConnection(String apiKey) async { Future<RoadAlertProviderStatus> testConnection(String apiKey) async {
try { try {
final headers = await _buildHeaders(apiKey: apiKey);
final uri = Uri.parse('$backendBaseUrl/api/v1/alerts/ops/health'); final uri = Uri.parse('$backendBaseUrl/api/v1/alerts/ops/health');
final res = await _client.get(uri).timeout(const Duration(seconds: 4)); final res = await _client.get(uri, headers: headers).timeout(const Duration(seconds: 4));
if (res.statusCode == 200) { if (res.statusCode == 200) {
final data = json.decode(res.body); final data = json.decode(res.body);
final providers = data['providers'] as List<dynamic>? ?? []; final providers = data['providers'] as List<dynamic>? ?? [];
@ -62,6 +88,9 @@ class ZylaRoadAlertProvider implements RoadAlertProvider {
} }
return RoadAlertProviderStatus.connected; return RoadAlertProviderStatus.connected;
} }
if (res.statusCode == 401 || res.statusCode == 403) {
return RoadAlertProviderStatus.authError;
}
return RoadAlertProviderStatus.networkError; return RoadAlertProviderStatus.networkError;
} catch (e) { } catch (e) {
if (kDebugMode) { if (kDebugMode) {
@ -103,7 +132,14 @@ class ZylaRoadAlertProvider implements RoadAlertProvider {
); );
try { try {
final response = await _client.get(uri).timeout(const Duration(seconds: 5)); final headers = await _buildHeaders(apiKey: apiKey);
final response = await _client.get(uri, headers: headers).timeout(const Duration(seconds: 5));
if (response.statusCode == 401 || response.statusCode == 403) {
if (kDebugMode) {
debugPrint('[ZylaRoadAlertProvider] Backend rejected request with auth status ${response.statusCode}');
}
return [];
}
if (response.statusCode != 200) { if (response.statusCode != 200) {
if (kDebugMode) { if (kDebugMode) {
debugPrint('[ZylaRoadAlertProvider] Backend returned status ${response.statusCode}'); debugPrint('[ZylaRoadAlertProvider] Backend returned status ${response.statusCode}');
@ -157,7 +193,7 @@ class ZylaRoadAlertProvider implements RoadAlertProvider {
final expiresAtStr = raw['expires_at_utc']?.toString(); final expiresAtStr = raw['expires_at_utc']?.toString();
final expiresAt = expiresAtStr != null ? DateTime.tryParse(expiresAtStr) : null; final expiresAt = expiresAtStr != null ? DateTime.tryParse(expiresAtStr) : null;
final confidence = (raw['confidence'] as num?)?.toDouble() ?? 0.7; final confidence = (raw['confidence'] as num?)?.toDouble();
// Approximate distance // Approximate distance
final dLat = (lat - userLat) * 111320.0; final dLat = (lat - userLat) * 111320.0;

View File

@ -337,6 +337,131 @@ void main() {
final status = await provider.testConnection(''); final status = await provider.testConnection('');
expect(status, equals(RoadAlertProviderStatus.connected)); expect(status, equals(RoadAlertProviderStatus.connected));
}); });
test('transmits Authorization bearer token when getAuthToken is configured', () async {
String? capturedAuthHeader;
final mockClient = MockClient((request) async {
capturedAuthHeader = request.headers['authorization'] ?? request.headers['Authorization'];
return http.Response(
json.encode({
'alerts': [],
'count': 0,
'cached': false,
'attribution': 'Waze / Zyla Labs #4814'
}),
200,
headers: {'content-type': 'application/json'},
);
});
final provider = ZylaRoadAlertProvider(
client: mockClient,
backendBaseUrl: 'http://127.0.0.1:8000',
getAuthToken: () async => 'firebase-test-id-token-12345',
);
await provider.getAlerts(
latitude: 34.2560,
longitude: -85.1650,
radiusMeters: 5000,
);
expect(capturedAuthHeader, equals('Bearer firebase-test-id-token-12345'));
});
test('testConnection returns authError on 401 Unauthorized', () async {
final mockClient = MockClient((request) async {
return http.Response(
json.encode({'detail': 'Missing authentication credentials'}),
401,
headers: {'content-type': 'application/json'},
);
});
final provider = ZylaRoadAlertProvider(
client: mockClient,
backendBaseUrl: 'http://127.0.0.1:8000',
);
final status = await provider.testConnection('');
expect(status, equals(RoadAlertProviderStatus.authError));
});
test('getAlerts handles 401 Unauthorized gracefully by returning empty list', () async {
final mockClient = MockClient((request) async {
return http.Response(
json.encode({'detail': 'Invalid authentication token'}),
401,
headers: {'content-type': 'application/json'},
);
});
final provider = ZylaRoadAlertProvider(
client: mockClient,
backendBaseUrl: 'http://127.0.0.1:8000',
);
final alerts = await provider.getAlerts(
latitude: 34.2560,
longitude: -85.1650,
radiusMeters: 5000,
);
expect(alerts, isEmpty);
});
test('preserves honest 0.0 confidence and null confidence without synthetic 0.7 inflation', () async {
final mockClient = MockClient((request) async {
final responseBody = json.encode({
'provider_id': 'zyla_waze_4814',
'source': 'Zyla Waze Data #4814',
'alerts': [
{
'id': 'zyla_zero_conf',
'category': 'police',
'subtype': 'POLICE_VISIBLE',
'latitude': 34.2560,
'longitude': -85.1650,
'road_name': 'Martha Berry Hwy',
'description': 'Radar Trap',
'reported_at_utc': '2026-10-10T12:00:00Z',
'confidence': 0.0,
'source': 'Zyla Waze Data #4814'
},
{
'id': 'zyla_null_conf',
'category': 'hazard',
'subtype': 'HAZARD_ON_ROAD',
'latitude': 34.2570,
'longitude': -85.1660,
'road_name': 'Turner McCall Blvd',
'description': 'Road Obstacle',
'reported_at_utc': '2026-10-10T12:01:00Z',
'confidence': null,
'source': 'Zyla Waze Data #4814'
}
],
'count': 2,
});
return http.Response(responseBody, 200, headers: {'content-type': 'application/json'});
});
final provider = ZylaRoadAlertProvider(
client: mockClient,
backendBaseUrl: 'http://127.0.0.1:8000',
);
final alerts = await provider.getAlerts(
latitude: 34.2560,
longitude: -85.1650,
radiusMeters: 5000,
);
expect(alerts.length, equals(2));
expect(alerts[0].confidence, equals(0.0));
expect(alerts[1].confidence, isNull);
});
}); });
group('Georgia511RoadAlertProvider Tests', () { group('Georgia511RoadAlertProvider Tests', () {

View File

@ -123,7 +123,7 @@ void main() {
tester, tester,
) async { ) async {
await open(tester); await open(tester);
await tester.tap(find.text('HAZARD / GRAVEL / DEBRIS')); await tester.tap(find.text('Road Hazard'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
// Deliberately resolve after the original implementation disposed its ref. // Deliberately resolve after the original implementation disposed its ref.
location.current.complete( location.current.complete(
@ -160,7 +160,7 @@ void main() {
tester, tester,
) async { ) async {
await open(tester); await open(tester);
await tester.tap(find.text('HAZARD / GRAVEL / DEBRIS')); await tester.tap(find.text('Road Hazard'));
await tester.pump(); await tester.pump();
await tester.tap(find.byIcon(Icons.close)); await tester.tap(find.byIcon(Icons.close));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
@ -185,13 +185,13 @@ void main() {
testWidgets('repeated taps create one report', (tester) async { testWidgets('repeated taps create one report', (tester) async {
await open(tester); await open(tester);
await tester.tap(find.text('HAZARD / GRAVEL / DEBRIS')); await tester.tap(find.text('Road Hazard'));
await tester.tap(find.text('HAZARD / GRAVEL / DEBRIS')); await tester.tap(find.text('Road Hazard'));
await tester.pump(); await tester.pump();
expect( expect(
tester tester
.widget<ElevatedButton>( .widget<ElevatedButton>(
find.widgetWithText(ElevatedButton, 'HAZARD / GRAVEL / DEBRIS'), find.widgetWithText(ElevatedButton, 'Road Hazard'),
) )
.onPressed, .onPressed,
isNull, isNull,
@ -219,7 +219,7 @@ void main() {
tester, tester,
) async { ) async {
await open(tester); await open(tester);
await tester.tap(find.text('HAZARD / GRAVEL / DEBRIS')); await tester.tap(find.text('Road Hazard'));
location.current.completeError(const PermissionDeniedException('denied')); location.current.completeError(const PermissionDeniedException('denied'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(alerts.reports, isEmpty); expect(alerts.reports, isEmpty);
@ -232,7 +232,7 @@ void main() {
testWidgets('close cancels even before its exit animation finishes', (tester) async { testWidgets('close cancels even before its exit animation finishes', (tester) async {
await open(tester); await open(tester);
await tester.tap(find.text('HAZARD / GRAVEL / DEBRIS')); await tester.tap(find.text('Road Hazard'));
await tester.tap(find.byIcon(Icons.close)); await tester.tap(find.byIcon(Icons.close));
location.current.complete(_position()); location.current.complete(_position());
await tester.pumpAndSettle(); await tester.pumpAndSettle();
@ -242,13 +242,13 @@ void main() {
testWidgets('GPS timeout falls back and missing GPS permits retry', (tester) async { testWidgets('GPS timeout falls back and missing GPS permits retry', (tester) async {
await open(tester); await open(tester);
await tester.tap(find.text('HAZARD / GRAVEL / DEBRIS')); await tester.tap(find.text('Road Hazard'));
await tester.pump(const Duration(seconds: 5)); await tester.pump(const Duration(seconds: 5));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(alerts.reports, isEmpty); expect(alerts.reports, isEmpty);
expect(find.text('Cannot report: GPS location unavailable.'), findsOneWidget); expect(find.text('Cannot report: GPS location unavailable.'), findsOneWidget);
expect(tester.widget<ElevatedButton>(find.widgetWithText( expect(tester.widget<ElevatedButton>(find.widgetWithText(
ElevatedButton, 'HAZARD / GRAVEL / DEBRIS')).onPressed, isNotNull); ElevatedButton, 'Road Hazard')).onPressed, isNotNull);
location.current.complete(_position()); location.current.complete(_position());
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(alerts.reports, isEmpty); expect(alerts.reports, isEmpty);
@ -258,7 +258,7 @@ void main() {
final lastKnown = Completer<Position?>(); final lastKnown = Completer<Position?>();
location.lastKnown = () => lastKnown.future; location.lastKnown = () => lastKnown.future;
await open(tester); await open(tester);
await tester.tap(find.text('HAZARD / GRAVEL / DEBRIS')); await tester.tap(find.text('Road Hazard'));
location.current.completeError(StateError('GPS unavailable')); location.current.completeError(StateError('GPS unavailable'));
await tester.pump(); await tester.pump();
await tester.pump(const Duration(seconds: 2)); await tester.pump(const Duration(seconds: 2));
@ -272,7 +272,7 @@ void main() {
testWidgets('confirmed cloud outcome has a persisted fake document', (tester) async { testWidgets('confirmed cloud outcome has a persisted fake document', (tester) async {
final firestore = FakeFirebaseFirestore(); final firestore = FakeFirebaseFirestore();
await open(tester, service: SwarmRoadAlertService(firestore: firestore)); await open(tester, service: SwarmRoadAlertService(firestore: firestore));
await tester.tap(find.text('HAZARD / GRAVEL / DEBRIS')); await tester.tap(find.text('Road Hazard'));
location.current.complete(_position()); location.current.complete(_position());
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(alerts.reports, hasLength(1)); expect(alerts.reports, hasLength(1));
@ -291,7 +291,7 @@ void main() {
} }
'''); ''');
await open(tester, service: SwarmRoadAlertService(firestore: firestore)); await open(tester, service: SwarmRoadAlertService(firestore: firestore));
await tester.tap(find.text('HAZARD / GRAVEL / DEBRIS')); await tester.tap(find.text('Road Hazard'));
location.current.complete(_position()); location.current.complete(_position());
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(alerts.reports, hasLength(1)); expect(alerts.reports, hasLength(1));
@ -302,7 +302,7 @@ void main() {
testWidgets('pending acknowledgement returns locally without retrying write', (tester) async { testWidgets('pending acknowledgement returns locally without retrying write', (tester) async {
final firestore = _DelayedFirestore(); final firestore = _DelayedFirestore();
await open(tester, service: SwarmRoadAlertService(firestore: firestore)); await open(tester, service: SwarmRoadAlertService(firestore: firestore));
await tester.tap(find.text('HAZARD / GRAVEL / DEBRIS')); await tester.tap(find.text('Road Hazard'));
location.current.complete(_position()); location.current.complete(_position());
await tester.pump(); await tester.pump();
await tester.pump(const Duration(seconds: 6)); await tester.pump(const Duration(seconds: 6));
@ -319,7 +319,7 @@ void main() {
testWidgets('removing provider scope during cloud write avoids disposed service', (tester) async { testWidgets('removing provider scope during cloud write avoids disposed service', (tester) async {
final firestore = _DelayedFirestore(); final firestore = _DelayedFirestore();
await open(tester, service: SwarmRoadAlertService(firestore: firestore)); await open(tester, service: SwarmRoadAlertService(firestore: firestore));
await tester.tap(find.text('HAZARD / GRAVEL / DEBRIS')); await tester.tap(find.text('Road Hazard'));
location.current.complete(_position()); location.current.complete(_position());
await tester.pump(); await tester.pump();
expect(firestore.writes, 1); expect(firestore.writes, 1);