diff --git a/work/meshai/env/firms.py b/work/meshai/env/firms.py index d049b5f..5f54db8 100644 --- a/work/meshai/env/firms.py +++ b/work/meshai/env/firms.py @@ -77,12 +77,8 @@ class FIRMSAdapter: self._last_tick = now - if not self._map_key: - if not self._last_error: - logger.warning("FIRMS: No MAP_KEY configured, skipping") - self._last_error = "No MAP_KEY configured" - return False - + # MAP_KEY is optional — a blank key builds a keyless request path for + # a key-injecting proxy (e.g. Conduit); bbox is the real prerequisite. if not self._bbox or len(self._bbox) != 4: if not self._last_error: logger.warning("FIRMS: No valid bbox configured, skipping") @@ -100,7 +96,10 @@ class FIRMSAdapter: # Format bbox as west,south,east,north bbox_str = ",".join(str(c) for c in self._bbox) - url = f"{self._base_url}/{self._map_key}/{self._source}/{bbox_str}/{self._day_range}" + if self._map_key: + url = f"{self._base_url}/{self._map_key}/{self._source}/{bbox_str}/{self._day_range}" + else: + url = f"{self._base_url}/{self._source}/{bbox_str}/{self._day_range}" headers = { "User-Agent": "MeshAI/1.0", diff --git a/work/meshai/env/traffic.py b/work/meshai/env/traffic.py index 4ede072..f185db6 100644 --- a/work/meshai/env/traffic.py +++ b/work/meshai/env/traffic.py @@ -74,8 +74,9 @@ class TomTomTrafficAdapter: self._daily_requests = 0 self._daily_reset = now - # No API key or corridors - if not self._api_key or not self._corridors: + # No corridors configured (the key is optional — a blank key builds + # a keyless request for a key-injecting proxy, e.g. Conduit) + if not self._corridors: return False # Check tick interval @@ -146,11 +147,10 @@ class TomTomTrafficAdapter: Returns: Event dict or None on error """ - params = { - "point": f"{lat},{lon}", - "key": self._api_key, - "unit": "MPH", - } + params = {"point": f"{lat},{lon}"} + if self._api_key: + params["key"] = self._api_key + params["unit"] = "MPH" url = f"{self._base_url}?{urlencode(params)}" diff --git a/work/tests/test_adapter_firms.py b/work/tests/test_adapter_firms.py index 3b30b54..0b5e566 100644 --- a/work/tests/test_adapter_firms.py +++ b/work/tests/test_adapter_firms.py @@ -1,7 +1,7 @@ """Tests for FIRMS adapter Phase 2.6 — to_event() method.""" import time -from unittest.mock import MagicMock +from unittest.mock import MagicMock, patch import pytest @@ -152,3 +152,98 @@ def test_to_event_does_not_raise_on_corrupted_dict(adapter): # Should not raise event = adapter.to_event(evt) assert event is None + + +# ============================================================ +# OPTIONAL MAP_KEY — Conduit keyless-request support +# +# The FIRMS MAP_KEY moves to Conduit's keystore; meshai sends a keyless +# request and Conduit injects the key into the URL path downstream. The +# key gate must not idle the adapter, and a blank key must build a URL +# with NO map_key path segment. A configured key must produce a +# byte-identical URL to before (backward compat). +# ============================================================ + +class _FakeCM: + """Minimal context manager mimicking urlopen()'s return value.""" + + def __init__(self, body: bytes): + self._body = body + + def __enter__(self): + return self + + def __exit__(self, *exc): + return False + + def read(self): + return self._body + + +# Empty-body CSV (header only, no data rows) short-circuits _parse_csv +# before any fusion/persistence path is touched. +_EMPTY_CSV = b"latitude,longitude,confidence,frp,acq_date,acq_time\n" + + +def test_fetch_url_with_map_key_is_byte_identical(mock_config): + """With MAP_KEY configured, the built URL is unchanged (backward compat).""" + adapter = FIRMSAdapter(mock_config, region_anchors=[], fires_adapter=None) + captured = {} + + def fake_urlopen(req, timeout=30): + captured["url"] = req.full_url + return _FakeCM(_EMPTY_CSV) + + with patch("meshai.env.firms.urlopen", side_effect=fake_urlopen): + adapter._fetch() + + assert captured["url"] == ( + "https://firms.modaps.eosdis.nasa.gov/api/area/csv/" + "test-key/VIIRS_SNPP_NRT/-117,42,-114,44/1" + ) + + +def test_fetch_url_blank_map_key_omits_path_segment(mock_config): + """With a blank MAP_KEY, the URL is built keyless (no map_key path + segment) for a key-injecting proxy (Conduit) to complete downstream.""" + mock_config.map_key = "" + adapter = FIRMSAdapter(mock_config, region_anchors=[], fires_adapter=None) + captured = {} + + def fake_urlopen(req, timeout=30): + captured["url"] = req.full_url + return _FakeCM(_EMPTY_CSV) + + with patch("meshai.env.firms.urlopen", side_effect=fake_urlopen): + adapter._fetch() + + assert captured["url"] == ( + "https://firms.modaps.eosdis.nasa.gov/api/area/csv/" + "VIIRS_SNPP_NRT/-117,42,-114,44/1" + ) + assert "test-key" not in captured["url"] + + +def test_tick_does_not_idle_when_map_key_blank(mock_config): + """A blank MAP_KEY must not gate tick(); bbox is the real prerequisite.""" + mock_config.map_key = "" + adapter = FIRMSAdapter(mock_config, region_anchors=[], fires_adapter=None) + + with patch.object(adapter, "_fetch", return_value=False) as fetch: + result = adapter.tick() + + fetch.assert_called_once() + assert result is False + + +def test_tick_still_gates_on_no_bbox(mock_config): + """bbox remains a real prerequisite — tick() still idles without it, + even with MAP_KEY configured.""" + mock_config.bbox = [] + adapter = FIRMSAdapter(mock_config, region_anchors=[], fires_adapter=None) + + with patch.object(adapter, "_fetch") as fetch: + result = adapter.tick() + + fetch.assert_not_called() + assert result is False diff --git a/work/tests/test_adapter_traffic.py b/work/tests/test_adapter_traffic.py index 22ecf0e..6a2fcb1 100644 --- a/work/tests/test_adapter_traffic.py +++ b/work/tests/test_adapter_traffic.py @@ -249,3 +249,93 @@ def test_fetch_point_non400_http_error_sets_last_error(mock_config): assert result is None assert adapter._last_error == "HTTP 503" assert adapter._consecutive_errors == 1 + + +# ============================================================ +# OPTIONAL API KEY — Conduit keyless-request support +# +# TomTom key moves to Conduit's keystore; meshai sends a keyless request +# and Conduit injects the key downstream. Key gate must not idle the +# adapter, and a blank key must build a keyless URL. A configured key +# must produce a byte-identical URL to before (backward compat). +# ============================================================ + +class _FakeCM: + """Minimal context manager mimicking urlopen()'s return value.""" + + def __init__(self, body: bytes): + self._body = body + + def __enter__(self): + return self + + def __exit__(self, *exc): + return False + + def read(self): + return self._body + + +def test_fetch_point_url_with_key_is_byte_identical(mock_config): + """With a key configured, the built URL is unchanged (backward compat).""" + adapter = TomTomTrafficAdapter(mock_config) + captured = {} + + def fake_urlopen(req, timeout=15): + captured["url"] = req.full_url + return _FakeCM(b'{"flowSegmentData": {}}') + + with patch("meshai.env.traffic.urlopen", side_effect=fake_urlopen): + adapter._fetch_point("Cole Rd", 43.6, -116.3, 0.0) + + assert captured["url"] == ( + "https://api.tomtom.com/traffic/services/4/flowSegmentData/relative0/10/json" + "?point=43.6%2C-116.3&key=test-key&unit=MPH" + ) + + +def test_fetch_point_url_blank_key_omits_key_param(mock_config): + """With a blank key, the URL is built keyless (no key= param) for a + key-injecting proxy (Conduit) to complete downstream.""" + mock_config.api_key = "" + adapter = TomTomTrafficAdapter(mock_config) + captured = {} + + def fake_urlopen(req, timeout=15): + captured["url"] = req.full_url + return _FakeCM(b'{"flowSegmentData": {}}') + + with patch("meshai.env.traffic.urlopen", side_effect=fake_urlopen): + adapter._fetch_point("Cole Rd", 43.6, -116.3, 0.0) + + assert captured["url"] == ( + "https://api.tomtom.com/traffic/services/4/flowSegmentData/relative0/10/json" + "?point=43.6%2C-116.3&unit=MPH" + ) + assert "key=" not in captured["url"] + + +def test_tick_does_not_idle_when_key_blank(mock_config): + """A blank key must not gate tick(); corridors is the real prerequisite.""" + mock_config.api_key = "" + mock_config.corridors = [{"name": "Cole Rd", "lat": 43.6, "lon": -116.3}] + adapter = TomTomTrafficAdapter(mock_config) + + with patch.object(adapter, "_fetch_all", return_value=True) as fetch_all: + result = adapter.tick() + + fetch_all.assert_called_once() + assert result is True + + +def test_tick_still_gates_on_no_corridors(mock_config): + """Corridors remain a real prerequisite — tick() still idles without them, + even with a key configured.""" + mock_config.corridors = [] + adapter = TomTomTrafficAdapter(mock_config) + + with patch.object(adapter, "_fetch_all") as fetch_all: + result = adapter.tick() + + fetch_all.assert_not_called() + assert result is False