diff --git a/apps/compliance/admin.py b/apps/compliance/admin.py index e1ce50e..0abbe06 100644 --- a/apps/compliance/admin.py +++ b/apps/compliance/admin.py @@ -1,9 +1,10 @@ import json -from django.conf import settings from django.contrib import admin from django.utils.html import format_html +from apps.core.tiles import browser_tile_url + from .models import ( ProductSpecification, SpecParameter, Facility, OriginZone, CollectorGroup, HarvestIntake, IntakeBatchLineage, @@ -45,6 +46,10 @@ class OriginZoneAdmin(admin.ModelAdmin): Display only. It does not validate the geometry and it does not verify the zone — those are separate pieces of work. + + The tile URL is the same-origin path from apps/core/tiles.py, so the map + keeps working when a viewer's network cannot reach a third-party tile + server. """ if obj is None: return "Save this zone, then its area can be drawn here." @@ -60,12 +65,12 @@ class OriginZoneAdmin(admin.ModelAdmin): return format_html( '
' + 'border-radius:.375rem;background:#eef2f5">' '' "Drawn from the stored polygon. A view, not a check: it does not validate the " "geometry and it does not verify the zone.
", json.dumps(obj.geometry), - settings.LEAFLET_TILE_URL, + browser_tile_url(), ) diff --git a/apps/compliance/static/js/ixg_zone_map.js b/apps/compliance/static/js/ixg_zone_map.js index 562ff2b..184cc08 100644 --- a/apps/compliance/static/js/ixg_zone_map.js +++ b/apps/compliance/static/js/ixg_zone_map.js @@ -34,10 +34,27 @@ var map = L.map(el, { scrollWheelZoom: false }); if (el.dataset.tileUrl) { - L.tileLayer(el.dataset.tileUrl, { + var tiles = L.tileLayer(el.dataset.tileUrl, { maxZoom: 19, attribution: OSM_ATTRIBUTION, }).addTo(map); + + // If the tile source cannot be reached, the polygon still draws but the + // background stays blank. Say so, rather than leaving the viewer to wonder + // whether the record itself is broken. + var note = null; + tiles.on("tileerror", function () { + if (note) { + return; + } + note = document.createElement("p"); + note.style.cssText = + "max-width:900px;margin-top:.5rem;font-size:.8rem;color:#b45309"; + note.textContent = + "Map imagery could not be loaded. The area below is still drawn from the " + + "stored coordinates, so the shape can still be checked."; + el.insertAdjacentElement("afterend", note); + }); } var layer = L.geoJSON(geometry, { diff --git a/apps/core/tests.py b/apps/core/tests.py index 0c0b213..fe46597 100644 --- a/apps/core/tests.py +++ b/apps/core/tests.py @@ -1,12 +1,23 @@ -"""Every portal page must actually render. +"""Cross-cutting checks: every portal page renders, and map tiles are served +from our own origin. -Nothing in the suite was rendering the ops or portal HTML pages, which is how a -view can lose a line — a `template_name`, say — and 500 in front of a user while -68 tests still pass. These sweeps fetch each read-only page with an empty -database, so they cover the empty-state branches as well as the templates. +Neither was covered before. The page sweep exists because a view can lose a +line — a `template_name`, say — and 500 in front of a user while the rest of the +suite stays green; it fetches each read-only page with an empty database, so it +covers the empty-state branches too. The tile tests cover the map's only +external dependency, which used to be fetched by the viewer's browser. """ +import shutil +import tempfile +import urllib.error +from pathlib import Path +from unittest.mock import MagicMock, patch + +from django.conf import settings from django.contrib.auth import get_user_model -from django.test import Client, TestCase +from django.test import Client, TestCase, override_settings + +from .tiles import browser_tile_url, upstream_url User = get_user_model() @@ -57,3 +68,88 @@ class PortalPagesRenderTests(TestCase): def test_buyer_pages_render(self): self.sweep(self.make_user("buyer-sweep@ixg.local", "buyer"), PORTAL_ROUTES, "portal.ixgltd.com") + + +class TileProxyTests(TestCase): + """Tiles come from our origin, so the map does not depend on the viewer's + network being able to reach a third-party tile server.""" + + def setUp(self): + self.cache = Path(tempfile.mkdtemp(prefix="ixg-tile-cache-")) + self.addCleanup(shutil.rmtree, self.cache, True) + override = override_settings(LEAFLET_TILE_CACHE_DIR=str(self.cache)) + override.enable() + self.addCleanup(override.disable) + + self.user = User.objects.create_user( + email="tile-viewer@ixg.local", password="pass1234", role="admin", + full_name="Tile Viewer", is_staff=True, + ) + self.anon = Client(SERVER_NAME="app.ixgltd.com") + self.viewer = Client(SERVER_NAME="app.ixgltd.com") + self.viewer.force_login(self.user) + + @staticmethod + def fake_upstream(payload): + """Stand in for urllib.request.urlopen returning a tile.""" + response = MagicMock() + response.read.return_value = payload + context = MagicMock() + context.__enter__.return_value = response + return lambda *a, **k: context + + def test_a_viewer_must_be_signed_in(self): + self.assertEqual(self.anon.get("/tiles/13/4085/3448.png").status_code, 403) + + def test_tiles_outside_the_zoom_range_are_refused(self): + self.assertEqual(self.viewer.get("/tiles/20/1/1.png").status_code, 404) + # x beyond the span of that zoom level + self.assertEqual(self.viewer.get("/tiles/1/5/1.png").status_code, 404) + + def test_a_tile_is_fetched_from_the_upstream_and_served(self): + payload = b"\x89PNG\r\n\x1a\n fake tile" + with patch("apps.core.tiles.urllib.request.urlopen", self.fake_upstream(payload)): + r = self.viewer.get("/tiles/13/4085/3448.png") + self.assertEqual(r.status_code, 200) + self.assertEqual(r["Content-Type"], "image/png") + self.assertEqual(r.content, payload) + self.assertTrue((self.cache / "13" / "4085" / "3448.png").is_file()) + + def test_a_second_request_is_served_from_the_cache(self): + payload = b"\x89PNG\r\n\x1a\n cached tile" + first = MagicMock(side_effect=self.fake_upstream(payload)) + with patch("apps.core.tiles.urllib.request.urlopen", first): + self.viewer.get("/tiles/13/4085/3448.png") + self.assertEqual(first.call_count, 1) + + with patch("apps.core.tiles.urllib.request.urlopen") as second: + r = self.viewer.get("/tiles/13/4085/3448.png") + self.assertEqual(second.call_count, 0, "cache should have been used") + + self.assertEqual(r.status_code, 200) + self.assertEqual(r.content, payload) + + def test_an_unreachable_upstream_answers_an_empty_tile_not_an_error_page(self): + with patch( + "apps.core.tiles.urllib.request.urlopen", + side_effect=urllib.error.HTTPError("u", 403, "Forbidden", None, None), + ): + r = self.viewer.get("/tiles/13/4085/3448.png") + self.assertEqual(r.status_code, 204) + self.assertEqual(r.content, b"") + + def test_browser_tile_url_is_same_origin_when_the_proxy_is_on(self): + self.assertEqual(browser_tile_url(), "/tiles/{z}/{x}/{y}.png") + + def test_browser_tile_url_is_the_upstream_when_the_proxy_is_off(self): + with override_settings(LEAFLET_TILE_PROXY_ENABLED=False): + self.assertEqual(browser_tile_url(), settings.LEAFLET_TILE_URL) + + def test_the_upstream_url_gets_the_coordinates_substituted(self): + with override_settings( + LEAFLET_TILE_URL="https://example.invalid/{s}/{z}/{x}/{y}.png" + ): + self.assertEqual( + upstream_url(13, 4085, 3448), + "https://example.invalid/a/13/4085/3448.png", + ) diff --git a/apps/core/tiles.py b/apps/core/tiles.py new file mode 100644 index 0000000..725e5a7 --- /dev/null +++ b/apps/core/tiles.py @@ -0,0 +1,112 @@ +"""Map tiles, served from our own origin. + +The origin-zone map is drawn from a stored polygon, but the background imagery +comes from a public tile server. That makes the map the only part of the admin +page that depends on a *third-party* domain, and it is the part that breaks: +a viewer whose network, proxy or browser filter refuses tile.openstreetmap.org +(the refusal arrives as a 403) gets a map that never draws, even though the page, +the Leaflet bundle and the polygon data are all fine and same-origin. + +So the browser asks us for tiles and we fetch them: + + browser -> /tiles/{z}/{x}/{y}.png ->