Add map tiles integration to proxy the OpenStreetMap base map (#180441)

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Erik <erik@montnemery.com>
This commit is contained in:
Bram Kragten
2026-09-01 11:33:43 +02:00
co-authored by Claude Opus 5 Erik
parent eb78b3a4c0
commit c4a4e5c087
13 changed files with 1533 additions and 0 deletions
Generated
+2
View File
@@ -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
@@ -11,6 +11,7 @@
"file_upload",
"http",
"lovelace",
"map_tiles",
"onboarding",
"repairs",
"search",
@@ -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 <img>, 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]})
+111
View File
@@ -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
@@ -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 = (
'&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
" 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)
@@ -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"
}
+343
View File
@@ -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,
)
+1
View File
@@ -102,6 +102,7 @@ NO_IOT_CLASS = [
"logbook",
"logger",
"lovelace",
"map_tiles",
"media_source",
"moisture",
"motion",
+1
View File
@@ -2046,6 +2046,7 @@ NO_QUALITY_SCALE = [
"logbook",
"logger",
"lovelace",
"map_tiles",
"media_source",
"moisture",
"motion",
+1
View File
@@ -0,0 +1 @@
"""Tests for the Map tiles integration."""
+245
View File
@@ -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
+650
View File
@@ -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": (
'&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
),
"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": "<html>not json</html>"}, 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 <img> 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
+2
View File
@@ -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',