diff --git a/CODEOWNERS b/CODEOWNERS
index 57f2174a35f9..3037314ccdbb 100644
--- a/CODEOWNERS
+++ b/CODEOWNERS
@@ -1107,6 +1107,8 @@ CLAUDE.md @home-assistant/core
/tests/components/lyric/ @timmo001
/homeassistant/components/madvr/ @iloveicedgreentea
/tests/components/madvr/ @iloveicedgreentea
+/homeassistant/components/map_tiles/ @home-assistant/core
+/tests/components/map_tiles/ @home-assistant/core
/homeassistant/components/marantz_infrared/ @balloob
/tests/components/marantz_infrared/ @balloob
/homeassistant/components/mastodon/ @fabaff @andrew-codechimp
diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json
index 3dc2553190aa..932979620cf5 100644
--- a/homeassistant/components/frontend/manifest.json
+++ b/homeassistant/components/frontend/manifest.json
@@ -11,6 +11,7 @@
"file_upload",
"http",
"lovelace",
+ "map_tiles",
"onboarding",
"repairs",
"search",
diff --git a/homeassistant/components/map_tiles/__init__.py b/homeassistant/components/map_tiles/__init__.py
new file mode 100644
index 000000000000..b576174e8789
--- /dev/null
+++ b/homeassistant/components/map_tiles/__init__.py
@@ -0,0 +1,78 @@
+"""The Map tiles integration.
+
+Serves the frontend's base map - OpenStreetMap vector tiles, their TileJSON,
+glyphs and sprites, and raster tiles for devices that cannot render vector ones.
+
+A proxy is needed because the OSMF tile policy wants requests identified via
+`User-Agent` or `Referer`, and a browser can send neither: both are forbidden
+header names, and the default referrer (the page origin) would expose the
+user's Nabu Casa installation URL.
+"""
+
+from collections import deque
+from datetime import datetime
+import secrets
+from typing import Any
+
+import voluptuous as vol
+
+from homeassistant.components import websocket_api
+from homeassistant.core import HomeAssistant, callback
+from homeassistant.helpers import config_validation as cv
+from homeassistant.helpers.event import async_track_time_interval
+from homeassistant.helpers.typing import ConfigType
+
+from .cache import MapTilesCache
+from .const import DATA_ACCESS_TOKENS, DOMAIN, TOKEN_CHANGE_INTERVAL, TOKEN_SIZE
+from .views import (
+ MapTilesGlyphsView,
+ MapTilesRasterView,
+ MapTilesSpriteIndexView,
+ MapTilesSpriteSheetView,
+ MapTilesTileJsonView,
+ MapTilesVectorView,
+)
+
+CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
+
+
+async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
+ """Set up the Map tiles integration."""
+ # Leaflet asks for raster tiles with an
, which can carry no header, so
+ # the token has to live in the URL.
+ access_tokens: deque[str] = deque([secrets.token_hex(TOKEN_SIZE)], maxlen=2)
+ hass.data[DATA_ACCESS_TOKENS] = access_tokens
+
+ @callback
+ def _rotate_token(_now: datetime) -> None:
+ """Rotate the access token."""
+ access_tokens.append(secrets.token_hex(TOKEN_SIZE))
+
+ async_track_time_interval(
+ hass, _rotate_token, TOKEN_CHANGE_INTERVAL, cancel_on_shutdown=True
+ )
+
+ cache = MapTilesCache(hass)
+ for view in (
+ MapTilesTileJsonView,
+ MapTilesVectorView,
+ MapTilesRasterView,
+ MapTilesGlyphsView,
+ MapTilesSpriteIndexView,
+ MapTilesSpriteSheetView,
+ ):
+ hass.http.register_view(view(hass, cache))
+
+ websocket_api.async_register_command(hass, ws_access_token)
+ return True
+
+
+@callback
+@websocket_api.websocket_command({vol.Required("type"): "map_tiles/access_token"})
+def ws_access_token(
+ hass: HomeAssistant,
+ connection: websocket_api.ActiveConnection,
+ msg: dict[str, Any],
+) -> None:
+ """Return the current map tiles access token."""
+ connection.send_result(msg["id"], {"token": hass.data[DATA_ACCESS_TOKENS][-1]})
diff --git a/homeassistant/components/map_tiles/cache.py b/homeassistant/components/map_tiles/cache.py
new file mode 100644
index 000000000000..93d863e948d2
--- /dev/null
+++ b/homeassistant/components/map_tiles/cache.py
@@ -0,0 +1,111 @@
+"""Cache for the Map tiles integration."""
+
+import asyncio
+from collections import OrderedDict
+from collections.abc import Callable, Coroutine
+from dataclasses import dataclass
+import time
+from typing import Any, Final
+
+from homeassistant.core import HomeAssistant
+
+from .const import CACHE_MAX_BYTES, DOMAIN, MAX_CONCURRENT_FETCHES
+
+# Approximate bookkeeping cost of one entry (key, tuple, Asset, timestamp and
+# dict slot), charged so tiny bodies cannot grow the entry count without bound.
+_ENTRY_OVERHEAD: Final = 300
+
+
+@dataclass(frozen=True, slots=True)
+class Asset:
+ """An upstream response body plus the Content-Encoding it is stored in.
+
+ Kept compressed as upstream sent it, so a dense vector tile is not
+ re-compressed for every client that requests it.
+ """
+
+ body: bytes
+ encoding: str | None
+ ttl: float | None = None
+
+
+type FetchCallback = Callable[[], Coroutine[Any, Any, Asset | None]]
+
+
+def _entry_size(key: str, asset: Asset) -> int:
+ """Return what an entry counts against the size ceiling."""
+ return len(asset.body) + len(key) + _ENTRY_OVERHEAD
+
+
+class MapTilesCache:
+ """A bounded in-memory cache of upstream responses, keyed by asset path.
+
+ Entries are never dropped for being stale, so only the size ceiling evicts,
+ least recently used first.
+ """
+
+ def __init__(self, hass: HomeAssistant) -> None:
+ """Initialize the cache."""
+ self._hass = hass
+ self._max_bytes = CACHE_MAX_BYTES
+ self._entries: OrderedDict[str, tuple[Asset, float]] = OrderedDict()
+ self._size = 0
+ self._fetches: dict[str, asyncio.Task[Asset | None]] = {}
+ self._fetch_semaphore = asyncio.Semaphore(MAX_CONCURRENT_FETCHES)
+
+ async def async_get(self, key: str, ttl: int, fetch: FetchCallback) -> Asset | None:
+ """Return the entry for key, fetching or refreshing it as needed.
+
+ ttl is the fallback refresh interval, used when the stored asset carries
+ no upstream max-age of its own.
+ """
+ if (entry := self._entries.get(key)) is None:
+ return await self._async_fetch(key, fetch)
+
+ self._entries.move_to_end(key)
+ asset, stored_at = entry
+ if time.monotonic() - stored_at > (ttl if asset.ttl is None else asset.ttl):
+ # Serve the stale entry now and refresh in the background, so an
+ # upstream outage degrades to slightly old tiles, not to no map.
+ self._hass.async_create_background_task(
+ self._async_fetch(key, fetch), f"{DOMAIN} refresh {key}"
+ )
+ return asset
+
+ def _store(self, key: str, asset: Asset) -> None:
+ """Store an entry, evicting until back under the size ceiling."""
+ if (previous := self._entries.pop(key, None)) is not None:
+ self._size -= _entry_size(key, previous[0])
+
+ self._entries[key] = (asset, time.monotonic())
+ self._size += _entry_size(key, asset)
+
+ while self._size > self._max_bytes and len(self._entries) > 1:
+ evicted_key, (evicted, _stored_at) = self._entries.popitem(last=False)
+ self._size -= _entry_size(evicted_key, evicted)
+
+ async def _async_fetch(self, key: str, fetch: FetchCallback) -> Asset | None:
+ """Fetch key upstream, joining a fetch already in flight for it."""
+ if (pending := self._fetches.get(key)) is None:
+ pending = self._hass.async_create_task(
+ self._async_fetch_and_store(key, fetch), f"{DOMAIN} fetch {key}"
+ )
+ if not pending.done():
+ self._fetches[key] = pending
+ pending.add_done_callback(lambda _task: self._fetches.pop(key, None))
+
+ # Shielded: one client navigating away must not cancel the fetch the
+ # others are waiting on.
+ return await asyncio.shield(pending)
+
+ async def _async_fetch_and_store(
+ self, key: str, fetch: FetchCallback
+ ) -> Asset | None:
+ """Fetch key upstream and store what comes back."""
+ # Bounds parallel upstream requests and the in-flight body memory they
+ # hold; the store afterwards is synchronous and needs no slot.
+ async with self._fetch_semaphore:
+ asset = await fetch()
+ if asset is not None:
+ self._store(key, asset)
+ return asset
diff --git a/homeassistant/components/map_tiles/const.py b/homeassistant/components/map_tiles/const.py
new file mode 100644
index 000000000000..8add11bd3eab
--- /dev/null
+++ b/homeassistant/components/map_tiles/const.py
@@ -0,0 +1,88 @@
+"""Constants for the Map tiles integration."""
+
+from collections import deque
+from datetime import timedelta
+import re
+from typing import Final
+
+from aiohttp import ClientTimeout
+
+from homeassistant.const import __version__
+from homeassistant.util.hass_dict import HassKey
+
+DOMAIN: Final = "map_tiles"
+DATA_ACCESS_TOKENS: HassKey[deque[str]] = HassKey(DOMAIN)
+
+VECTOR_URL: Final = "https://vector.openstreetmap.org"
+RASTER_URL: Final = "https://tile.openstreetmap.org"
+TILEJSON_URL: Final = f"{VECTOR_URL}/shortbread_v1/tilejson.json"
+UPSTREAM_TIMEOUT: Final = ClientTimeout(total=10)
+
+CONTACT: Final = "abuse@home-assistant.io"
+UPSTREAM_HEADERS: Final = {
+ # OSM blocks referer-less browser requests; the accepted alternative is an
+ # identifying application `User-Agent`, which this proxy supplies because
+ # a browser cannot.
+ "User-Agent": (
+ f"HomeAssistant/{__version__} (+https://www.home-assistant.io; {CONTACT})"
+ ),
+ # Pinned to gzip so cached bodies are in an encoding every client accepts;
+ # the session default advertises whichever codecs happen to be installed.
+ "Accept-Encoding": "gzip",
+}
+
+# Fallback refresh intervals, used only when upstream sends no Cache-Control
+# max-age to honor. Intervals, not lifetimes: an expired entry is never dropped,
+# because it is what keeps the map up while upstream is unreachable.
+TILE_TTL: Final = 7 * 24 * 60 * 60
+# Glyphs and sprites are pinned to an upstream release and never change.
+ASSET_TTL: Final = 30 * 24 * 60 * 60
+# Short: the TileJSON is how upstream would announce a moved tile endpoint.
+TILEJSON_TTL: Final = 60 * 60
+
+# The OSMF asks consumers to cache tiles for at least a week; the max-age
+# delegates that to the browser cache instead of this instance's memory.
+TILE_MAX_AGE: Final = 7 * 24 * 60 * 60
+ASSET_MAX_AGE: Final = 30 * 24 * 60 * 60
+TILEJSON_MAX_AGE: Final = 5 * 60
+
+# A server side cache is needed because the access token rotates, which changes
+# every URL and empties every browser cache with it. In memory rather than on
+# disk: Home Assistant runs on SD cards, and losing the working set on restart
+# costs a few dozen requests.
+CACHE_MAX_BYTES: Final = 32 * 1024 * 1024
+
+# Far above any legitimate asset (tiles top out at a few hundred KB), so only a
+# hostile or broken upstream hits them; they bound what a single response can
+# make this process hold in memory, on the wire and after decompression.
+MAX_FETCH_BYTES: Final = 8 * 1024 * 1024
+MAX_DECOMPRESSED_BYTES: Final = 32 * 1024 * 1024
+
+# Bounds both in-flight body memory (this many concurrent fetches, each capped
+# at MAX_FETCH_BYTES) and how many parallel requests reach the volunteer-run OSM
+# servers at once.
+MAX_CONCURRENT_FETCHES: Final = 16
+
+# MapLibre overzooms above the source maxzoom, so nothing legitimate asks for a
+# vector tile past z14.
+VECTOR_MAX_ZOOM: Final = 14
+RASTER_MAX_ZOOM: Final = 19
+
+# OSM's own TileJSON omits "contributors", which their guidelines ask for.
+ATTRIBUTION: Final = (
+ '© OpenStreetMap'
+ " contributors"
+)
+
+FONTSTACK_RE: Final = re.compile(
+ r"^[A-Za-z0-9 _-]{1,64}(?:,[A-Za-z0-9 _-]{1,64}){0,7}$"
+)
+GLYPH_RANGE_RE: Final = re.compile(r"^\d{1,5}-\d{1,5}\.pbf$")
+SPRITE_SET_RE: Final = re.compile(r"^[a-z0-9_-]{1,32}$")
+SPRITE_NAME_RE: Final = re.compile(r"^sprites(?:@2x)?$")
+
+# Bytes of entropy per access token.
+TOKEN_SIZE: Final = 32
+
+# Two tokens are live at a time, so one stays valid for 30 to 60 minutes.
+TOKEN_CHANGE_INTERVAL: Final = timedelta(minutes=30)
diff --git a/homeassistant/components/map_tiles/manifest.json b/homeassistant/components/map_tiles/manifest.json
new file mode 100644
index 000000000000..c14e45b1c6d7
--- /dev/null
+++ b/homeassistant/components/map_tiles/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "map_tiles",
+ "name": "Map tiles",
+ "codeowners": ["@home-assistant/core"],
+ "config_flow": false,
+ "dependencies": ["http", "websocket_api"],
+ "documentation": "https://www.home-assistant.io/integrations/map_tiles",
+ "integration_type": "system",
+ "quality_scale": "internal"
+}
diff --git a/homeassistant/components/map_tiles/views.py b/homeassistant/components/map_tiles/views.py
new file mode 100644
index 000000000000..e5b7880d190b
--- /dev/null
+++ b/homeassistant/components/map_tiles/views.py
@@ -0,0 +1,343 @@
+"""HTTP views for the Map tiles integration."""
+
+from functools import partial
+import gzip
+from http import HTTPStatus
+import json
+import logging
+from typing import Final, override
+import zlib
+
+from aiohttp import ClientError, hdrs, web
+
+from homeassistant.components.http import KEY_AUTHENTICATED, HomeAssistantView
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
+from homeassistant.helpers.json import json_bytes
+
+from .cache import Asset, MapTilesCache
+from .const import (
+ ASSET_MAX_AGE,
+ ASSET_TTL,
+ ATTRIBUTION,
+ DATA_ACCESS_TOKENS,
+ FONTSTACK_RE,
+ GLYPH_RANGE_RE,
+ MAX_DECOMPRESSED_BYTES,
+ MAX_FETCH_BYTES,
+ RASTER_MAX_ZOOM,
+ RASTER_URL,
+ SPRITE_NAME_RE,
+ SPRITE_SET_RE,
+ TILE_MAX_AGE,
+ TILE_TTL,
+ TILEJSON_MAX_AGE,
+ TILEJSON_TTL,
+ TILEJSON_URL,
+ UPSTREAM_HEADERS,
+ UPSTREAM_TIMEOUT,
+ VECTOR_MAX_ZOOM,
+ VECTOR_URL,
+)
+
+_LOGGER = logging.getLogger(__name__)
+
+# A root-relative path works behind any reverse proxy; an absolute URL built
+# from the request's Host header would not if the proxy does not forward it.
+VECTOR_TILE_PATH = "/api/map_tiles/vector/{z}/{x}/{y}.mvt"
+
+# Cap coordinate length before int(), which is expensive on huge digit strings.
+MAX_COORDINATE_DIGITS = 8
+
+GZIP: Final = "gzip"
+
+
+def _gzip_decompress(body: bytes) -> bytes:
+ """Decompress a gzip body, refusing pathological expansion."""
+ decompressor = zlib.decompressobj(wbits=16 + zlib.MAX_WBITS)
+ decompressed = decompressor.decompress(body, MAX_DECOMPRESSED_BYTES)
+ if decompressor.unconsumed_tail:
+ raise ValueError("Decompressed body too large")
+ if not decompressor.eof or decompressor.unused_data:
+ raise ValueError("Malformed gzip body")
+ return decompressed
+
+
+def _upstream_ttl(cache_control: str) -> float | None:
+ """Return upstream's max-age in seconds, or None when it sends none."""
+ for directive in cache_control.split(","):
+ name, _, value = directive.strip().partition("=")
+ if name.lower() == "max-age" and value.isdigit():
+ return float(value)
+ return None
+
+
+class _MapTilesView(HomeAssistantView):
+ """Serve one class of map asset, from the cache or from upstream."""
+
+ requires_auth = False
+
+ content_type: str
+ ttl: int
+ max_age: int
+
+ def __init__(self, hass: HomeAssistant, cache: MapTilesCache) -> None:
+ """Initialize the view."""
+ self._hass = hass
+ self._cache = cache
+
+ def _authenticate(self, request: web.Request) -> None:
+ """Authenticate via the standard middleware or a map tiles query token."""
+ access_tokens = self._hass.data[DATA_ACCESS_TOKENS]
+ if request[KEY_AUTHENTICATED] or request.query.get("token") in access_tokens:
+ return
+ if hdrs.AUTHORIZATION in request.headers:
+ # A real Bearer attempt, so let the ban middleware count it.
+ raise web.HTTPUnauthorized
+ # Most likely a query token that expired while a dashboard sat open, so
+ # 403 rather than banning the user's own IP over it.
+ raise web.HTTPForbidden
+
+ async def _async_serve(self, key: str, url: str) -> web.Response:
+ """Serve an asset from the cache, fetching it upstream on a miss.
+
+ A gzip-encoded asset is served compressed to every client; Accept-Encoding
+ is intentionally not checked, since every browser accepts gzip. There is
+ therefore no identity variant, and hence no Vary on Accept-Encoding.
+ """
+ asset = await self._cache.async_get(
+ key, self.ttl, partial(self._async_fetch, url)
+ )
+ if asset is None:
+ return web.Response(status=HTTPStatus.BAD_GATEWAY)
+
+ headers = {hdrs.CACHE_CONTROL: f"private, max-age={self.max_age}"}
+ if asset.encoding:
+ headers[hdrs.CONTENT_ENCODING] = asset.encoding
+
+ return web.Response(
+ body=asset.body, content_type=self.content_type, headers=headers
+ )
+
+ async def _async_fetch(self, url: str) -> Asset | None:
+ """Fetch url upstream, returning None on any upstream failure."""
+ session = async_get_clientsession(self._hass)
+ # Keep the body in the encoding upstream sent, so a gzipped asset is
+ # cached compressed instead of re-compressed for every client.
+ try:
+ async with session.get(
+ url,
+ headers=UPSTREAM_HEADERS,
+ timeout=UPSTREAM_TIMEOUT,
+ auto_decompress=False,
+ ) as response:
+ if response.status >= HTTPStatus.BAD_REQUEST:
+ _LOGGER.debug("Upstream %s returned %s", url, response.status)
+ return None
+ # Accumulated in chunks so a hostile upstream cannot make this
+ # process buffer an arbitrarily large response. An empty body
+ # is a legitimate answer: a vector tile with nothing in it
+ # comes back as a short 200, not a 204 or a 404.
+ chunks: list[bytes] = []
+ read = 0
+ async for chunk in response.content.iter_chunked(64 * 1024):
+ read += len(chunk)
+ if read > MAX_FETCH_BYTES:
+ _LOGGER.warning(
+ "Upstream %s body exceeds %s bytes, refusing it",
+ url,
+ MAX_FETCH_BYTES,
+ )
+ return None
+ chunks.append(chunk)
+ except (ClientError, TimeoutError) as err:
+ _LOGGER.debug("Upstream %s failed: %s", url, err)
+ return None
+
+ body = b"".join(chunks)
+ ttl = _upstream_ttl(response.headers.get(hdrs.CACHE_CONTROL, ""))
+ return Asset(body, response.headers.get(hdrs.CONTENT_ENCODING), ttl)
+
+
+class _MapTilesTileView(_MapTilesView):
+ """Serve map tiles."""
+
+ ttl = TILE_TTL
+ max_age = TILE_MAX_AGE
+ max_zoom: int
+ upstream: str
+ key_template: str
+
+ async def get(
+ self, request: web.Request, z: str, x: str, y: str
+ ) -> web.StreamResponse:
+ """Handle a GET request for a tile."""
+ self._authenticate(request)
+
+ if any(len(part) > MAX_COORDINATE_DIGITS for part in (z, x, y)):
+ return web.Response(status=HTTPStatus.NOT_FOUND)
+
+ zoom, column, row = int(z), int(x), int(y)
+ if zoom > self.max_zoom or column >= 2**zoom or row >= 2**zoom:
+ return web.Response(status=HTTPStatus.NOT_FOUND)
+
+ coordinates = {"z": zoom, "x": column, "y": row}
+ return await self._async_serve(
+ self.key_template.format(**coordinates),
+ self.upstream.format(**coordinates),
+ )
+
+
+class MapTilesVectorView(_MapTilesTileView):
+ """Serve vector tiles."""
+
+ name = "api:map_tiles:vector"
+ url = "/api/map_tiles/vector/{z:[0-9]+}/{x:[0-9]+}/{y:[0-9]+}.mvt"
+ content_type = "application/vnd.mapbox-vector-tile"
+ max_zoom = VECTOR_MAX_ZOOM
+ upstream = f"{VECTOR_URL}/shortbread_v1/{{z}}/{{x}}/{{y}}.mvt"
+ key_template = "vector/{z}/{x}/{y}.mvt"
+
+
+class MapTilesRasterView(_MapTilesTileView):
+ """Serve raster tiles, for devices that cannot render vector ones."""
+
+ name = "api:map_tiles:raster"
+ url = "/api/map_tiles/raster/{z:[0-9]+}/{x:[0-9]+}/{y:[0-9]+}.png"
+ content_type = "image/png"
+ max_zoom = RASTER_MAX_ZOOM
+ upstream = f"{RASTER_URL}/{{z}}/{{x}}/{{y}}.png"
+ key_template = "raster/{z}/{x}/{y}.png"
+
+
+class MapTilesGlyphsView(_MapTilesView):
+ """Serve the SDF glyphs the map labels are drawn from."""
+
+ name = "api:map_tiles:glyphs"
+ url = "/api/map_tiles/fonts/{fontstack}/{glyph_range}"
+ content_type = "application/x-protobuf"
+ ttl = ASSET_TTL
+ max_age = ASSET_MAX_AGE
+
+ async def get(
+ self, request: web.Request, fontstack: str, glyph_range: str
+ ) -> web.StreamResponse:
+ """Handle a GET request for a glyph range."""
+ self._authenticate(request)
+
+ if not FONTSTACK_RE.match(fontstack) or not GLYPH_RANGE_RE.match(glyph_range):
+ return web.Response(status=HTTPStatus.NOT_FOUND)
+
+ return await self._async_serve(
+ f"fonts/{fontstack}/{glyph_range}",
+ f"{VECTOR_URL}/styles/shortbread/fonts/{fontstack}/{glyph_range}",
+ )
+
+
+class _MapTilesSpritesView(_MapTilesView):
+ """Serve the icon sprites the map symbols come from."""
+
+ ttl = ASSET_TTL
+ max_age = ASSET_MAX_AGE
+ extension: str
+
+ async def get(
+ self, request: web.Request, sprite_set: str, name: str
+ ) -> web.StreamResponse:
+ """Handle a GET request for a sprite set."""
+ self._authenticate(request)
+
+ if not SPRITE_SET_RE.match(sprite_set) or not SPRITE_NAME_RE.match(name):
+ return web.Response(status=HTTPStatus.NOT_FOUND)
+
+ path = f"sprites/{sprite_set}/{name}{self.extension}"
+ return await self._async_serve(path, f"{VECTOR_URL}/styles/shortbread/{path}")
+
+
+class MapTilesSpriteIndexView(_MapTilesSpritesView):
+ """Serve the sprite index."""
+
+ name = "api:map_tiles:sprite_index"
+ url = "/api/map_tiles/sprites/{sprite_set}/{name}.json"
+ content_type = "application/json"
+ extension = ".json"
+
+
+class MapTilesSpriteSheetView(_MapTilesSpritesView):
+ """Serve the sprite sheet."""
+
+ name = "api:map_tiles:sprite_sheet"
+ url = "/api/map_tiles/sprites/{sprite_set}/{name}.png"
+ content_type = "image/png"
+ extension = ".png"
+
+
+class MapTilesTileJsonView(_MapTilesView):
+ """Serve the TileJSON, rewritten to point back at this instance."""
+
+ name = "api:map_tiles:tilejson"
+ url = "/api/map_tiles/tilejson.json"
+ content_type = "application/json"
+ ttl = TILEJSON_TTL
+ max_age = TILEJSON_MAX_AGE
+
+ async def get(self, request: web.Request) -> web.StreamResponse:
+ """Handle a GET request for the TileJSON."""
+ self._authenticate(request)
+ return await self._async_serve("tilejson.json", TILEJSON_URL)
+
+ @override
+ async def _async_fetch(self, url: str) -> Asset | None:
+ """Fetch the upstream TileJSON and republish it as ours.
+
+ The zoom range is taken from upstream (clamped to what we serve); the
+ attribution and the advertised tile endpoint are replaced with this
+ proxy's own. The tile endpoint is pinned, so the vector fetch URL does
+ not follow the upstream template.
+ """
+ if (asset := await super()._async_fetch(url)) is None:
+ return None
+ return await self._hass.async_add_executor_job(self._rebuild, asset)
+
+ def _rebuild(self, asset: Asset) -> Asset | None:
+ """Rewrite the upstream TileJSON to point back at this instance."""
+ try:
+ tilejson = json.loads(
+ _gzip_decompress(asset.body) if asset.encoding else asset.body
+ )
+ except ValueError, zlib.error:
+ _LOGGER.error("Upstream TileJSON is not valid JSON")
+ return None
+
+ if not isinstance(tilejson, dict) or not tilejson.get("tiles"):
+ _LOGGER.error("Upstream TileJSON does not list any tiles")
+ return None
+
+ try:
+ # Clamped to what the tile view will actually serve.
+ minzoom = max(int(tilejson.get("minzoom", 0)), 0)
+ maxzoom = min(
+ int(tilejson.get("maxzoom", VECTOR_MAX_ZOOM)), VECTOR_MAX_ZOOM
+ )
+ except TypeError, ValueError, OverflowError:
+ _LOGGER.error("Upstream TileJSON zoom range is not a finite number")
+ return None
+
+ # The only body built locally, so the only one this integration gzips.
+ # Kept on upstream's refresh cadence: it is how a moved endpoint arrives.
+ return Asset(
+ gzip.compress(
+ json_bytes(
+ {
+ **tilejson,
+ "tiles": [VECTOR_TILE_PATH],
+ "minzoom": minzoom,
+ "maxzoom": maxzoom,
+ "attribution": ATTRIBUTION,
+ }
+ ),
+ mtime=0,
+ ),
+ GZIP,
+ asset.ttl,
+ )
diff --git a/script/hassfest/manifest.py b/script/hassfest/manifest.py
index fb23e8b3c957..b4942018234f 100644
--- a/script/hassfest/manifest.py
+++ b/script/hassfest/manifest.py
@@ -102,6 +102,7 @@ NO_IOT_CLASS = [
"logbook",
"logger",
"lovelace",
+ "map_tiles",
"media_source",
"moisture",
"motion",
diff --git a/script/hassfest/quality_scale.py b/script/hassfest/quality_scale.py
index 019e382a84a2..a299c596a973 100644
--- a/script/hassfest/quality_scale.py
+++ b/script/hassfest/quality_scale.py
@@ -2046,6 +2046,7 @@ NO_QUALITY_SCALE = [
"logbook",
"logger",
"lovelace",
+ "map_tiles",
"media_source",
"moisture",
"motion",
diff --git a/tests/components/map_tiles/__init__.py b/tests/components/map_tiles/__init__.py
new file mode 100644
index 000000000000..cfd48d64a703
--- /dev/null
+++ b/tests/components/map_tiles/__init__.py
@@ -0,0 +1 @@
+"""Tests for the Map tiles integration."""
diff --git a/tests/components/map_tiles/test_cache.py b/tests/components/map_tiles/test_cache.py
new file mode 100644
index 000000000000..ec58d9369eae
--- /dev/null
+++ b/tests/components/map_tiles/test_cache.py
@@ -0,0 +1,245 @@
+"""Tests for the Map tiles cache."""
+
+import asyncio
+from unittest.mock import patch
+
+from freezegun.api import FrozenDateTimeFactory
+import pytest
+
+from homeassistant.components.map_tiles.cache import (
+ _ENTRY_OVERHEAD,
+ Asset,
+ MapTilesCache,
+)
+from homeassistant.core import HomeAssistant
+
+_CACHE = "homeassistant.components.map_tiles.cache"
+
+TTL = 60
+
+BODY = b"0123456789"
+# What one entry with a single-character key counts against the ceiling.
+ENTRY_COST = len(BODY) + 1 + _ENTRY_OVERHEAD
+
+
+async def test_evicts_least_recently_used(hass: HomeAssistant) -> None:
+ """Test that the cache stays inside its ceiling, dropping the coldest first."""
+ with patch(f"{_CACHE}.CACHE_MAX_BYTES", 2 * ENTRY_COST):
+ cache = MapTilesCache(hass)
+ calls: list[str] = []
+
+ async def fetch(key: str) -> Asset:
+ calls.append(key)
+ return Asset(BODY, None)
+
+ for key in ("a", "b"):
+ await cache.async_get(key, TTL, lambda key=key: fetch(key))
+ # Reading "a" leaves "b" as the coldest entry.
+ await cache.async_get("a", TTL, lambda: fetch("a"))
+ await cache.async_get("c", TTL, lambda: fetch("c"))
+
+ assert calls == ["a", "b", "c"]
+
+ # "a" and "c" are still held; "b" was evicted to make room.
+ await cache.async_get("a", TTL, lambda: fetch("a"))
+ await cache.async_get("c", TTL, lambda: fetch("c"))
+ assert calls == ["a", "b", "c"]
+
+ await cache.async_get("b", TTL, lambda: fetch("b"))
+ assert calls == ["a", "b", "c", "b"]
+
+
+async def test_entry_larger_than_the_ceiling_is_kept(hass: HomeAssistant) -> None:
+ """Test that a tile bigger than the whole cache is still served from it."""
+ with patch(f"{_CACHE}.CACHE_MAX_BYTES", 10):
+ cache = MapTilesCache(hass)
+ calls: list[str] = []
+
+ async def fetch() -> Asset:
+ calls.append("fetched")
+ return Asset(b"a dense city tile at a low zoom", None)
+
+ await cache.async_get("big", TTL, fetch)
+ await cache.async_get("big", TTL, fetch)
+
+ assert calls == ["fetched"]
+
+
+async def test_empty_bodies_count_against_the_ceiling(hass: HomeAssistant) -> None:
+ """Test that entries with empty bodies cannot grow the cache without bound."""
+ with patch(f"{_CACHE}.CACHE_MAX_BYTES", 3 * (1 + _ENTRY_OVERHEAD)):
+ cache = MapTilesCache(hass)
+ calls: list[str] = []
+
+ async def fetch(key: str) -> Asset:
+ calls.append(key)
+ return Asset(b"", None)
+
+ for key in ("a", "b", "c", "d", "e"):
+ await cache.async_get(key, TTL, lambda key=key: fetch(key))
+
+ # The per-entry overhead pushed "a" out despite its zero-length body.
+ await cache.async_get("a", TTL, lambda: fetch("a"))
+ assert calls == ["a", "b", "c", "d", "e", "a"]
+
+
+async def test_the_encoding_is_cached_with_the_body(hass: HomeAssistant) -> None:
+ """Test that a cached asset still knows how its bytes are encoded."""
+ cache = MapTilesCache(hass)
+
+ async def fetch() -> Asset:
+ return Asset(b"compressed bytes", "gzip")
+
+ assert await cache.async_get("key", TTL, fetch) == Asset(
+ b"compressed bytes", "gzip"
+ )
+ assert await cache.async_get("key", TTL, fetch) == Asset(
+ b"compressed bytes", "gzip"
+ )
+
+
+async def test_failed_fetch_is_not_stored(hass: HomeAssistant) -> None:
+ """Test that a failure is retried rather than remembered."""
+ cache = MapTilesCache(hass)
+ calls: list[str] = []
+
+ async def fetch() -> Asset | None:
+ calls.append("fetched")
+ return None
+
+ assert await cache.async_get("key", TTL, fetch) is None
+ assert await cache.async_get("key", TTL, fetch) is None
+ assert len(calls) == 2
+
+
+async def test_concurrent_requests_share_one_fetch(hass: HomeAssistant) -> None:
+ """Test that a map view asking for one tile many times asks upstream once."""
+ cache = MapTilesCache(hass)
+ released = asyncio.Event()
+ calls: list[str] = []
+
+ async def fetch() -> Asset:
+ calls.append("fetched")
+ await released.wait()
+ return Asset(b"tile", None)
+
+ waiting = [
+ asyncio.create_task(cache.async_get("key", TTL, fetch)) for _ in range(5)
+ ]
+ await asyncio.sleep(0)
+ released.set()
+
+ assert await asyncio.gather(*waiting) == [Asset(b"tile", None)] * 5
+ assert calls == ["fetched"]
+
+
+async def test_a_cancelled_client_does_not_cancel_the_fetch(
+ hass: HomeAssistant,
+) -> None:
+ """Test that one client navigating away leaves the others their tile."""
+ cache = MapTilesCache(hass)
+ released = asyncio.Event()
+
+ async def fetch() -> Asset:
+ await released.wait()
+ return Asset(b"tile", None)
+
+ leaving = asyncio.create_task(cache.async_get("key", TTL, fetch))
+ staying = asyncio.create_task(cache.async_get("key", TTL, fetch))
+ await asyncio.sleep(0)
+
+ leaving.cancel()
+ released.set()
+
+ assert await staying == Asset(b"tile", None)
+
+
+async def test_a_cancelled_fetch_does_not_poison_the_key(hass: HomeAssistant) -> None:
+ """Test that a key can be fetched again after its fetch task was cancelled."""
+ cache = MapTilesCache(hass)
+ released = asyncio.Event()
+ calls: list[str] = []
+
+ async def fetch() -> Asset:
+ calls.append("fetched")
+ await released.wait()
+ return Asset(b"tile", None)
+
+ waiting = asyncio.create_task(cache.async_get("key", TTL, fetch))
+ await asyncio.sleep(0)
+
+ # Cancel the fetch task itself, as a shutdown would.
+ cache._fetches["key"].cancel()
+ with pytest.raises(asyncio.CancelledError):
+ await waiting
+
+ released.set()
+ assert await cache.async_get("key", TTL, fetch) == Asset(b"tile", None)
+ assert len(calls) == 2
+
+
+async def test_stale_entry_is_refreshed_behind_the_response(
+ hass: HomeAssistant, freezer: FrozenDateTimeFactory
+) -> None:
+ """Test that an expired entry answers now and is replaced afterwards."""
+ cache = MapTilesCache(hass)
+ tiles = [b"first", b"second"]
+
+ async def fetch() -> Asset:
+ return Asset(tiles.pop(0), None)
+
+ assert await cache.async_get("key", TTL, fetch) == Asset(b"first", None)
+
+ freezer.tick(TTL + 1)
+ assert await cache.async_get("key", TTL, fetch) == Asset(b"first", None)
+ await hass.async_block_till_done()
+
+ assert await cache.async_get("key", TTL, fetch) == Asset(b"second", None)
+
+
+async def test_asset_ttl_overrides_the_fallback(
+ hass: HomeAssistant, freezer: FrozenDateTimeFactory
+) -> None:
+ """Test that an asset's own ttl decides staleness, not the caller's fallback."""
+ cache = MapTilesCache(hass)
+ tiles = [b"first", b"second"]
+
+ async def fetch() -> Asset:
+ return Asset(tiles.pop(0), None, ttl=TTL)
+
+ # The fallback is ten times the asset's own ttl, so only the latter can
+ # explain a refresh landing right after TTL elapses.
+ assert await cache.async_get("key", 10 * TTL, fetch) == Asset(b"first", None, TTL)
+
+ freezer.tick(TTL + 1)
+ assert await cache.async_get("key", 10 * TTL, fetch) == Asset(b"first", None, TTL)
+ await hass.async_block_till_done()
+
+ assert await cache.async_get("key", 10 * TTL, fetch) == Asset(b"second", None, TTL)
+
+
+async def test_concurrent_fetches_are_bounded(hass: HomeAssistant) -> None:
+ """Test that only so many upstream fetches run at once."""
+ with patch(f"{_CACHE}.MAX_CONCURRENT_FETCHES", 2):
+ cache = MapTilesCache(hass)
+ started = 0
+ release = asyncio.Event()
+
+ async def fetch() -> Asset:
+ nonlocal started
+ started += 1
+ await release.wait()
+ return Asset(b"tile", None)
+
+ tasks = [
+ asyncio.create_task(cache.async_get(key, TTL, fetch))
+ for key in ("a", "b", "c", "d", "e")
+ ]
+ # Let every task run up to the semaphore; only two get past it to fetch().
+ for _ in range(10):
+ await asyncio.sleep(0)
+ assert started == 2
+
+ release.set()
+ assert await asyncio.gather(*tasks) == [Asset(b"tile", None)] * 5
+ assert started == 5
diff --git a/tests/components/map_tiles/test_init.py b/tests/components/map_tiles/test_init.py
new file mode 100644
index 000000000000..d5413509cc8c
--- /dev/null
+++ b/tests/components/map_tiles/test_init.py
@@ -0,0 +1,650 @@
+"""Tests for the Map tiles integration."""
+
+from datetime import timedelta
+import gzip
+from http import HTTPStatus
+import math
+from unittest.mock import patch
+
+from aiohttp import ClientError
+from freezegun.api import FrozenDateTimeFactory
+import pytest
+
+from homeassistant.components.map_tiles.const import (
+ ASSET_MAX_AGE,
+ ATTRIBUTION,
+ DATA_ACCESS_TOKENS,
+ DOMAIN,
+ RASTER_URL,
+ TILE_MAX_AGE,
+ TILEJSON_URL,
+ TOKEN_CHANGE_INTERVAL,
+ VECTOR_URL,
+)
+from homeassistant.components.map_tiles.views import MapTilesVectorView
+from homeassistant.core import HomeAssistant
+from homeassistant.setup import async_setup_component
+
+from tests.common import async_fire_time_changed
+from tests.test_util.aiohttp import AiohttpClientMocker
+from tests.typing import ClientSessionGenerator, WebSocketGenerator
+
+VECTOR_TILE = b"\x1a\x0fnot-really-a-mvt"
+RASTER_TILE = b"\x89PNG\r\n\x1a\nnot-really-a-png"
+GLYPHS = b"not-really-a-glyph-range"
+SPRITES = b"\x89PNG\r\n\x1a\nnot-really-a-sprite-sheet"
+
+VECTOR_PATH = "/api/map_tiles/vector/12/2048/1361.mvt"
+RASTER_PATH = "/api/map_tiles/raster/12/2048/1361.png"
+GLYPHS_PATH = "/api/map_tiles/fonts/noto_sans_regular/0-255.pbf"
+TILEJSON_PATH = "/api/map_tiles/tilejson.json"
+
+VECTOR_UPSTREAM = f"{VECTOR_URL}/shortbread_v1/12/2048/1361.mvt"
+RASTER_UPSTREAM = f"{RASTER_URL}/12/2048/1361.png"
+GLYPHS_UPSTREAM = f"{VECTOR_URL}/styles/shortbread/fonts/noto_sans_regular/0-255.pbf"
+
+UPSTREAM_TILEJSON = {
+ "tilejson": "3.0.0",
+ "name": "shortbread",
+ "tiles": [f"{VECTOR_URL}/shortbread_v1/{{z}}/{{x}}/{{y}}.mvt"],
+ "minzoom": 0,
+ "maxzoom": 14,
+ "attribution": (
+ '© OpenStreetMap'
+ ),
+ "vector_layers": [{"id": "ocean"}],
+}
+
+
+@pytest.fixture(autouse=True)
+async def setup_map_tiles(hass: HomeAssistant) -> None:
+ """Set up the integration for every test."""
+ assert await async_setup_component(hass, "http", {"http": {}})
+ assert await async_setup_component(hass, DOMAIN, {})
+
+
+async def test_vector_tile(
+ hass: HomeAssistant,
+ hass_client: ClientSessionGenerator,
+ aioclient_mock: AiohttpClientMocker,
+) -> None:
+ """Test serving a vector tile."""
+ aioclient_mock.get(VECTOR_UPSTREAM, content=VECTOR_TILE)
+
+ client = await hass_client()
+ resp = await client.get(VECTOR_PATH)
+
+ assert resp.status == HTTPStatus.OK
+ assert resp.content_type == "application/vnd.mapbox-vector-tile"
+ assert resp.headers["Cache-Control"] == f"private, max-age={TILE_MAX_AGE}"
+ assert await resp.read() == VECTOR_TILE
+
+
+async def test_raster_tile(
+ hass: HomeAssistant,
+ hass_client: ClientSessionGenerator,
+ aioclient_mock: AiohttpClientMocker,
+) -> None:
+ """Test serving a raster tile."""
+ aioclient_mock.get(RASTER_UPSTREAM, content=RASTER_TILE)
+
+ client = await hass_client()
+ resp = await client.get(RASTER_PATH)
+
+ assert resp.status == HTTPStatus.OK
+ assert resp.content_type == "image/png"
+ assert await resp.read() == RASTER_TILE
+
+
+async def test_glyphs(
+ hass: HomeAssistant,
+ hass_client: ClientSessionGenerator,
+ aioclient_mock: AiohttpClientMocker,
+) -> None:
+ """Test serving a glyph range."""
+ aioclient_mock.get(GLYPHS_UPSTREAM, content=GLYPHS)
+
+ client = await hass_client()
+ resp = await client.get(GLYPHS_PATH)
+
+ assert resp.status == HTTPStatus.OK
+ assert resp.content_type == "application/x-protobuf"
+ assert resp.headers["Cache-Control"] == f"private, max-age={ASSET_MAX_AGE}"
+ assert await resp.read() == GLYPHS
+
+
+@pytest.mark.parametrize(
+ ("name", "content_type"),
+ [
+ ("sprites.json", "application/json"),
+ ("sprites@2x.json", "application/json"),
+ ("sprites.png", "image/png"),
+ ("sprites@2x.png", "image/png"),
+ ],
+)
+async def test_sprites(
+ hass: HomeAssistant,
+ hass_client: ClientSessionGenerator,
+ aioclient_mock: AiohttpClientMocker,
+ name: str,
+ content_type: str,
+) -> None:
+ """Test serving the sprite index and sheet, at both scales."""
+ aioclient_mock.get(
+ f"{VECTOR_URL}/styles/shortbread/sprites/basics/{name}", content=SPRITES
+ )
+
+ client = await hass_client()
+ resp = await client.get(f"/api/map_tiles/sprites/basics/{name}")
+
+ assert resp.status == HTTPStatus.OK
+ assert resp.content_type == content_type
+ assert await resp.read() == SPRITES
+
+
+async def test_compressed_tile_is_handed_on_as_it_arrived(
+ hass: HomeAssistant,
+ hass_client: ClientSessionGenerator,
+ aioclient_mock: AiohttpClientMocker,
+) -> None:
+ """Test that a tile is compressed upstream once, not here for every client."""
+ tile = b"a vector tile large enough to be worth compressing" * 100
+ aioclient_mock.get(
+ VECTOR_UPSTREAM,
+ content=gzip.compress(tile),
+ headers={"Content-Encoding": "gzip"},
+ )
+
+ client = await hass_client()
+ resp = await client.get(VECTOR_PATH)
+
+ assert resp.status == HTTPStatus.OK
+ assert resp.headers["Content-Encoding"] == "gzip"
+ assert "Vary" not in resp.headers
+ # The test client decompresses on read; a stale `Content-Encoding` over
+ # already-decoded bytes would make this read fail.
+ assert await resp.read() == tile
+
+
+async def test_gzip_is_served_regardless_of_accept_encoding(
+ hass: HomeAssistant,
+ hass_client: ClientSessionGenerator,
+ aioclient_mock: AiohttpClientMocker,
+) -> None:
+ """Test that a stored gzip asset is served compressed even for an identity request."""
+ tile = b"a vector tile large enough to be worth compressing" * 100
+ aioclient_mock.get(
+ VECTOR_UPSTREAM,
+ content=gzip.compress(tile),
+ headers={"Content-Encoding": "gzip"},
+ )
+
+ client = await hass_client()
+ resp = await client.get(VECTOR_PATH, headers={"Accept-Encoding": "identity"})
+
+ assert resp.status == HTTPStatus.OK
+ assert resp.headers["Content-Encoding"] == "gzip"
+ # The test client decompresses on read regardless of what it requested.
+ assert await resp.read() == tile
+
+
+async def test_png_served_verbatim(
+ hass: HomeAssistant,
+ hass_client: ClientSessionGenerator,
+ aioclient_mock: AiohttpClientMocker,
+) -> None:
+ """Test that a raster tile is served exactly as upstream sent it."""
+ aioclient_mock.get(RASTER_UPSTREAM, content=RASTER_TILE)
+
+ client = await hass_client()
+ resp = await client.get(RASTER_PATH)
+
+ assert resp.status == HTTPStatus.OK
+ assert "Content-Encoding" not in resp.headers
+ assert await resp.read() == RASTER_TILE
+
+
+async def test_upstream_headers_identify_home_assistant(
+ hass: HomeAssistant,
+ hass_client: ClientSessionGenerator,
+ aioclient_mock: AiohttpClientMocker,
+) -> None:
+ """Test that the upstream request says who is asking, and nothing else."""
+ aioclient_mock.get(RASTER_UPSTREAM, content=RASTER_TILE)
+
+ client = await hass_client()
+ await client.get(RASTER_PATH)
+
+ headers = aioclient_mock.mock_calls[0][3]
+ assert headers["User-Agent"].startswith("HomeAssistant/")
+ assert "abuse@home-assistant.io" in headers["User-Agent"]
+ # Pinned to gzip so cached bodies are in an encoding every client accepts.
+ assert headers["Accept-Encoding"] == "gzip"
+ # A Referer would be the instance hostname, which identifies an installation.
+ assert "Referer" not in headers
+ assert "Cookie" not in headers
+ assert "X-Requested-With" not in headers
+
+
+async def test_tilejson_is_rewritten(
+ hass: HomeAssistant,
+ hass_client: ClientSessionGenerator,
+ aioclient_mock: AiohttpClientMocker,
+) -> None:
+ """Test that the TileJSON points back at this instance and credits OSM."""
+ aioclient_mock.get(TILEJSON_URL, json=UPSTREAM_TILEJSON)
+
+ client = await hass_client()
+ resp = await client.get(TILEJSON_PATH)
+
+ assert resp.status == HTTPStatus.OK
+ tilejson = await resp.json()
+ assert tilejson["tiles"] == ["/api/map_tiles/vector/{z}/{x}/{y}.mvt"]
+ assert tilejson["attribution"] == ATTRIBUTION
+ assert "contributors" in tilejson["attribution"]
+ assert tilejson["minzoom"] == 0
+ assert tilejson["maxzoom"] == 14
+ # Passed through, so a change upstream needs no release here.
+ assert tilejson["vector_layers"] == [{"id": "ocean"}]
+
+
+async def test_tilejson_maxzoom_is_clamped(
+ hass: HomeAssistant,
+ hass_client: ClientSessionGenerator,
+ aioclient_mock: AiohttpClientMocker,
+) -> None:
+ """Test that the advertised zoom range cannot exceed what is served."""
+ aioclient_mock.get(TILEJSON_URL, json={**UPSTREAM_TILEJSON, "maxzoom": 20})
+
+ client = await hass_client()
+ resp = await client.get(TILEJSON_PATH)
+
+ assert (await resp.json())["maxzoom"] == 14
+
+
+@pytest.mark.parametrize(
+ "upstream",
+ [
+ pytest.param({"text": "not json"}, id="not json"),
+ pytest.param({"json": {"tiles": []}}, id="no tiles"),
+ pytest.param({"json": {}}, id="empty"),
+ pytest.param({"json": ["not", "an", "object"]}, id="not an object"),
+ pytest.param(
+ {"content": b"not gzip", "headers": {"Content-Encoding": "gzip"}},
+ id="lying content encoding",
+ ),
+ pytest.param(
+ {"json": {**UPSTREAM_TILEJSON, "minzoom": "low"}},
+ id="minzoom not numeric",
+ ),
+ pytest.param(
+ {"json": {**UPSTREAM_TILEJSON, "maxzoom": None}},
+ id="maxzoom not numeric",
+ ),
+ pytest.param(
+ {"json": {**UPSTREAM_TILEJSON, "minzoom": math.inf}},
+ id="minzoom not finite",
+ ),
+ ],
+)
+async def test_tilejson_unusable(
+ hass: HomeAssistant,
+ hass_client: ClientSessionGenerator,
+ aioclient_mock: AiohttpClientMocker,
+ upstream: dict[str, object],
+) -> None:
+ """Test that a TileJSON we cannot use is refused rather than passed on."""
+ aioclient_mock.get(TILEJSON_URL, **upstream)
+
+ client = await hass_client()
+ resp = await client.get(TILEJSON_PATH)
+
+ assert resp.status == HTTPStatus.BAD_GATEWAY
+
+
+@pytest.mark.parametrize(
+ "path",
+ [
+ pytest.param("/api/map_tiles/vector/15/16384/10888.mvt", id="vector above z14"),
+ pytest.param(
+ "/api/map_tiles/raster/20/524288/348416.png", id="raster above z19"
+ ),
+ pytest.param("/api/map_tiles/vector/2/4/1.mvt", id="x outside the pyramid"),
+ pytest.param("/api/map_tiles/vector/2/1/4.mvt", id="y outside the pyramid"),
+ pytest.param("/api/map_tiles/vector/1/123456789/1.mvt", id="absurd coordinate"),
+ pytest.param("/api/map_tiles/vector/a/1/1.mvt", id="non numeric coordinate"),
+ pytest.param("/api/map_tiles/fonts/../../etc/passwd/0-255.pbf", id="traversal"),
+ pytest.param("/api/map_tiles/fonts/noto_sans/nonsense.pbf", id="bad range"),
+ pytest.param("/api/map_tiles/fonts/noto_sans/0-255.exe", id="bad extension"),
+ pytest.param("/api/map_tiles/sprites/BASICS/sprites.png", id="bad sprite set"),
+ pytest.param("/api/map_tiles/sprites/basics/other.png", id="bad sprite name"),
+ ],
+)
+async def test_rejected_before_upstream(
+ hass: HomeAssistant,
+ hass_client: ClientSessionGenerator,
+ aioclient_mock: AiohttpClientMocker,
+ path: str,
+) -> None:
+ """Test that a request we cannot serve never reaches the OSMF."""
+ client = await hass_client()
+ resp = await client.get(path)
+
+ assert resp.status == HTTPStatus.NOT_FOUND
+ assert aioclient_mock.call_count == 0
+
+
+@pytest.mark.parametrize(
+ ("path", "upstream_url", "failure"),
+ [
+ pytest.param(
+ VECTOR_PATH,
+ VECTOR_UPSTREAM,
+ {"status": HTTPStatus.INTERNAL_SERVER_ERROR},
+ id="vector 500",
+ ),
+ pytest.param(
+ VECTOR_PATH,
+ VECTOR_UPSTREAM,
+ {"status": HTTPStatus.NOT_FOUND},
+ id="vector 404",
+ ),
+ pytest.param(
+ VECTOR_PATH, VECTOR_UPSTREAM, {"exc": ClientError}, id="vector unreachable"
+ ),
+ pytest.param(
+ VECTOR_PATH, VECTOR_UPSTREAM, {"exc": TimeoutError}, id="vector timeout"
+ ),
+ pytest.param(
+ RASTER_PATH, RASTER_UPSTREAM, {"exc": ClientError}, id="raster unreachable"
+ ),
+ pytest.param(
+ TILEJSON_PATH, TILEJSON_URL, {"exc": ClientError}, id="tilejson unreachable"
+ ),
+ pytest.param(
+ GLYPHS_PATH,
+ GLYPHS_UPSTREAM,
+ {"status": HTTPStatus.INTERNAL_SERVER_ERROR},
+ id="glyphs 500",
+ ),
+ ],
+)
+async def test_upstream_failure(
+ hass: HomeAssistant,
+ hass_client: ClientSessionGenerator,
+ aioclient_mock: AiohttpClientMocker,
+ path: str,
+ upstream_url: str,
+ failure: dict[str, object],
+) -> None:
+ """Test that an upstream failure is reported rather than remembered."""
+ aioclient_mock.get(upstream_url, **failure)
+
+ client = await hass_client()
+ assert (await client.get(path)).status == HTTPStatus.BAD_GATEWAY
+ assert (await client.get(path)).status == HTTPStatus.BAD_GATEWAY
+ assert aioclient_mock.call_count == 2
+
+
+async def test_empty_vector_tile_is_served(
+ hass: HomeAssistant,
+ hass_client: ClientSessionGenerator,
+ aioclient_mock: AiohttpClientMocker,
+) -> None:
+ """Test that a tile with nothing in it is an answer, not a failure."""
+ aioclient_mock.get(VECTOR_UPSTREAM, content=b"")
+
+ client = await hass_client()
+ resp = await client.get(VECTOR_PATH)
+
+ assert resp.status == HTTPStatus.OK
+ assert await resp.read() == b""
+
+
+async def test_oversized_upstream_body_is_refused(
+ hass: HomeAssistant,
+ hass_client: ClientSessionGenerator,
+ aioclient_mock: AiohttpClientMocker,
+) -> None:
+ """Test that a body larger than the fetch cap is refused rather than held."""
+ aioclient_mock.get(VECTOR_UPSTREAM, content=b"x" * 200)
+
+ client = await hass_client()
+ with patch("homeassistant.components.map_tiles.views.MAX_FETCH_BYTES", 100):
+ assert (await client.get(VECTOR_PATH)).status == HTTPStatus.BAD_GATEWAY
+ # Not cached, so the second request has to go out again.
+ assert (await client.get(VECTOR_PATH)).status == HTTPStatus.BAD_GATEWAY
+
+ assert aioclient_mock.call_count == 2
+
+
+async def test_tilejson_expanding_past_the_cap_is_refused(
+ hass: HomeAssistant,
+ hass_client: ClientSessionGenerator,
+ aioclient_mock: AiohttpClientMocker,
+) -> None:
+ """Test that a TileJSON whose gzip body expands past the cap is refused."""
+ aioclient_mock.get(
+ TILEJSON_URL,
+ content=gzip.compress(b"0" * 1000),
+ headers={"Content-Encoding": "gzip"},
+ )
+
+ client = await hass_client()
+ with patch("homeassistant.components.map_tiles.views.MAX_DECOMPRESSED_BYTES", 100):
+ resp = await client.get(TILEJSON_PATH)
+
+ assert resp.status == HTTPStatus.BAD_GATEWAY
+
+
+async def test_cached_tile_is_not_fetched_again(
+ hass: HomeAssistant,
+ hass_client: ClientSessionGenerator,
+ aioclient_mock: AiohttpClientMocker,
+) -> None:
+ """Test that a second request for a tile is served from the cache."""
+ aioclient_mock.get(VECTOR_UPSTREAM, content=VECTOR_TILE)
+
+ client = await hass_client()
+ assert (await client.get(VECTOR_PATH)).status == HTTPStatus.OK
+ assert (await client.get(VECTOR_PATH)).status == HTTPStatus.OK
+
+ assert aioclient_mock.call_count == 1
+
+
+# Shortened so the frozen clock can pass a tile's TTL without also passing the
+# lifetime of the test client's own access token.
+SHORT_TTL = 5
+
+
+async def test_stale_tile_is_served_while_it_refreshes(
+ hass: HomeAssistant,
+ hass_client: ClientSessionGenerator,
+ aioclient_mock: AiohttpClientMocker,
+ freezer: FrozenDateTimeFactory,
+) -> None:
+ """Test that an expired tile is served now and replaced behind the response."""
+ aioclient_mock.get(VECTOR_UPSTREAM, content=VECTOR_TILE)
+
+ client = await hass_client()
+ with patch.object(MapTilesVectorView, "ttl", SHORT_TTL):
+ assert await (await client.get(VECTOR_PATH)).read() == VECTOR_TILE
+
+ freezer.tick(SHORT_TTL + 1)
+ aioclient_mock.clear_requests()
+ aioclient_mock.get(VECTOR_UPSTREAM, content=b"a newer tile")
+
+ # The stale bytes come back straight away rather than waiting on upstream.
+ assert await (await client.get(VECTOR_PATH)).read() == VECTOR_TILE
+ await hass.async_block_till_done()
+
+ assert aioclient_mock.call_count == 1
+ assert await (await client.get(VECTOR_PATH)).read() == b"a newer tile"
+
+
+async def test_stale_tile_survives_an_upstream_outage(
+ hass: HomeAssistant,
+ hass_client: ClientSessionGenerator,
+ aioclient_mock: AiohttpClientMocker,
+ freezer: FrozenDateTimeFactory,
+) -> None:
+ """Test that an unreachable upstream degrades to old tiles, not to no map."""
+ aioclient_mock.get(VECTOR_UPSTREAM, content=VECTOR_TILE)
+
+ client = await hass_client()
+ with patch.object(MapTilesVectorView, "ttl", SHORT_TTL):
+ assert await (await client.get(VECTOR_PATH)).read() == VECTOR_TILE
+
+ freezer.tick(SHORT_TTL + 1)
+ aioclient_mock.clear_requests()
+ aioclient_mock.get(VECTOR_UPSTREAM, exc=ClientError)
+
+ resp = await client.get(VECTOR_PATH)
+ await hass.async_block_till_done()
+
+ assert resp.status == HTTPStatus.OK
+ assert await resp.read() == VECTOR_TILE
+ assert await (await client.get(VECTOR_PATH)).read() == VECTOR_TILE
+
+
+async def test_upstream_max_age_drives_the_refresh(
+ hass: HomeAssistant,
+ hass_client: ClientSessionGenerator,
+ aioclient_mock: AiohttpClientMocker,
+ freezer: FrozenDateTimeFactory,
+) -> None:
+ """Test that upstream's Cache-Control max-age, not the fallback TTL, refreshes."""
+ aioclient_mock.get(
+ VECTOR_UPSTREAM, content=VECTOR_TILE, headers={"Cache-Control": "max-age=5"}
+ )
+
+ client = await hass_client()
+ assert await (await client.get(VECTOR_PATH)).read() == VECTOR_TILE
+
+ # Past upstream's 5 s max-age but far below the multi-day fallback TTL.
+ freezer.tick(6)
+ aioclient_mock.clear_requests()
+ aioclient_mock.get(VECTOR_UPSTREAM, content=b"a newer tile")
+
+ assert await (await client.get(VECTOR_PATH)).read() == VECTOR_TILE
+ await hass.async_block_till_done()
+
+ assert aioclient_mock.call_count == 1
+ assert await (await client.get(VECTOR_PATH)).read() == b"a newer tile"
+
+
+async def test_token_query_param_authenticates(
+ hass: HomeAssistant,
+ hass_client_no_auth: ClientSessionGenerator,
+ aioclient_mock: AiohttpClientMocker,
+) -> None:
+ """Test that a token in the query string authenticates, as an
must."""
+ aioclient_mock.get(RASTER_UPSTREAM, content=RASTER_TILE)
+
+ token = hass.data[DATA_ACCESS_TOKENS][-1]
+ client = await hass_client_no_auth()
+ resp = await client.get(f"{RASTER_PATH}?token={token}")
+
+ assert resp.status == HTTPStatus.OK
+ assert await resp.read() == RASTER_TILE
+
+
+async def test_both_live_tokens_authenticate(
+ hass: HomeAssistant,
+ hass_client_no_auth: ClientSessionGenerator,
+ aioclient_mock: AiohttpClientMocker,
+ freezer: FrozenDateTimeFactory,
+) -> None:
+ """Test that a URL minted before the last rotation still loads."""
+ aioclient_mock.get(RASTER_UPSTREAM, content=RASTER_TILE)
+ client = await hass_client_no_auth()
+
+ freezer.tick(TOKEN_CHANGE_INTERVAL + timedelta(seconds=1))
+ async_fire_time_changed(hass)
+ await hass.async_block_till_done()
+
+ tokens = hass.data[DATA_ACCESS_TOKENS]
+ assert len(tokens) == 2
+ for token in tokens:
+ resp = await client.get(f"{RASTER_PATH}?token={token}")
+ assert resp.status == HTTPStatus.OK
+
+
+async def test_rotated_out_token_is_rejected(
+ hass: HomeAssistant,
+ hass_client_no_auth: ClientSessionGenerator,
+ freezer: FrozenDateTimeFactory,
+) -> None:
+ """Test that a token stops authenticating once two rotations have passed."""
+ client = await hass_client_no_auth()
+ token = hass.data[DATA_ACCESS_TOKENS][-1]
+
+ for _ in range(2):
+ freezer.tick(TOKEN_CHANGE_INTERVAL)
+ async_fire_time_changed(hass)
+ await hass.async_block_till_done()
+
+ resp = await client.get(f"{RASTER_PATH}?token={token}")
+ assert resp.status == HTTPStatus.FORBIDDEN
+
+
+@pytest.mark.parametrize(
+ "path",
+ [VECTOR_PATH, RASTER_PATH, GLYPHS_PATH, TILEJSON_PATH],
+)
+async def test_unauthenticated_request_is_forbidden(
+ hass: HomeAssistant,
+ hass_client_no_auth: ClientSessionGenerator,
+ aioclient_mock: AiohttpClientMocker,
+ path: str,
+) -> None:
+ """Test that an internet exposed instance is not an open tile proxy."""
+ client = await hass_client_no_auth()
+ resp = await client.get(path)
+
+ assert resp.status == HTTPStatus.FORBIDDEN
+ assert aioclient_mock.call_count == 0
+
+
+async def test_invalid_token_is_forbidden(
+ hass: HomeAssistant,
+ hass_client_no_auth: ClientSessionGenerator,
+) -> None:
+ """Test that a wrong query token does not count as a failed login."""
+ client = await hass_client_no_auth()
+ resp = await client.get(f"{RASTER_PATH}?token=not-a-token")
+
+ assert resp.status == HTTPStatus.FORBIDDEN
+
+
+async def test_invalid_bearer_token_is_unauthorized(
+ hass: HomeAssistant,
+ hass_client_no_auth: ClientSessionGenerator,
+) -> None:
+ """Test that a real Bearer attempt is a 401, so the ban middleware sees it."""
+ client = await hass_client_no_auth()
+ resp = await client.get(
+ RASTER_PATH, headers={"Authorization": "Bearer not-a-token"}
+ )
+
+ assert resp.status == HTTPStatus.UNAUTHORIZED
+
+
+async def test_ws_access_token(
+ hass: HomeAssistant,
+ hass_ws_client: WebSocketGenerator,
+ freezer: FrozenDateTimeFactory,
+) -> None:
+ """Test handing the current token to the frontend."""
+ client = await hass_ws_client(hass)
+
+ await client.send_json_auto_id({"type": "map_tiles/access_token"})
+ first = (await client.receive_json())["result"]["token"]
+ assert first == hass.data[DATA_ACCESS_TOKENS][-1]
+
+ freezer.tick(TOKEN_CHANGE_INTERVAL)
+ async_fire_time_changed(hass)
+ await hass.async_block_till_done()
+
+ await client.send_json_auto_id({"type": "map_tiles/access_token"})
+ assert (await client.receive_json())["result"]["token"] != first
diff --git a/tests/snapshots/test_bootstrap.ambr b/tests/snapshots/test_bootstrap.ambr
index 9b296dffe0df..ec5caad54151 100644
--- a/tests/snapshots/test_bootstrap.ambr
+++ b/tests/snapshots/test_bootstrap.ambr
@@ -65,6 +65,7 @@
'lock',
'logger',
'lovelace',
+ 'map_tiles',
'media_player',
'media_source',
'moisture',
@@ -175,6 +176,7 @@
'lock',
'logger',
'lovelace',
+ 'map_tiles',
'media_player',
'media_source',
'moisture',