[AZ-498] [AZ-499] Cycle 2 batch 11: satellite tiles + OWM hardening

AZ-498 — self-hosted satellite tiles + drop classic/satellite toggle:
- Single TILE_URL via getTileUrl() (mirrors getOwmBaseUrl/getApiBase
  pattern from AZ-449/AZ-450); env-var VITE_SATELLITE_TILE_URL with
  dev default http://localhost:5100/tiles/{z}/{x}/{y}.
- FlightMap + MiniMap render one TileLayer with
  crossOrigin="use-credentials" so Leaflet's <img> tile fetcher
  attaches the same-origin satellite-provider auth cookie.
- ImportMetaEnv + .env.example collapse the prior OSM/Esri pair into
  one var. The flights.planner.satellite i18n key is removed in
  lockstep across en.json + ua.json (parity preserved).
- E2E harness wired end-to-end: compose passes the new var to
  azaion-ui; tile-stub serves /tiles/{z}/{x}/{y} with
  Content-Type=image/jpeg + Cache-Control + ETag matching the
  contract; infrastructure.e2e.ts AC-2 asserts the new path; dead
  OSM defenses removed from EXTERNAL_HOSTS route guard.
- Fast-profile MSW handlers rewritten for the cookie-auth path shape.
- 8 colocated fast tests under src/features/flights/__tests__/.

AZ-499 — mission-planner OWM env-var hardening + AZ-482 source-scan
gap close:
- WeatherService.ts reads VITE_OWM_API_KEY + VITE_OWM_BASE_URL;
  fail-soft null when key unset (mirrors AZ-448 main-SPA contract).
  Public signature getWeatherData(lat, lon) preserved.
- mission-planner/.env.example + vite-env.d.ts declare both vars.
- New owm_key_in_source banned-deps kind scans src/ AND
  mission-planner/ for the rotated literal; STC-SEC1C row added to
  scripts/run-tests.sh; check-banned-deps.mjs dispatch extended.
- 7 fast tests under tests/mission_planner_weather.test.ts cover
  AC-1..AC-4 + trailing-slash + happy path + network-error fail-soft.

Spec drift (recorded in batch_11_report.md, user-approved Choose B
on 2026-05-12):
- AZ-498 AC-8 dropped (named tile_split_zoom* files belong to AZ-474
  image-annotation surface, not map tiles).
- 4 missing files added in-scope (msw tiles handler, tile-stub
  server, compose env, dead VITE_TILE_BASE_URL replaced).
- AZ-499 STC-S6 ID conflict resolved by using STC-SEC1C.

Pending USER ACTION (BLOCKING for AZ-499 close):
- Revoke OpenWeatherMap key 335799082893fad97fa36118b131f919 at
  home.openweathermap.org/api_keys; capture evidence on AZ-499.

Cross-workspace deploy gate (handled at autodev Step 16, not a
Step-10 blocker for AZ-498):
- satellite-provider cookie-auth on GET /tiles/{z}/{x}/{y}
  (separate AZAION ticket on the satellite-provider workspace).

Reports: _docs/03_implementation/batch_11_report.md and
_docs/03_implementation/reviews/batch_11_review.md (verdict
PASS_WITH_WARNINGS — 1 Low, pre-existing trim-trailing-slash
duplication across vite roots).

Static gates: STC-ARCH-01, STC-ARCH-02, STC-T1, STC-FP22, STC-FP23,
STC-SEC1C all PASS post-refactor. +15 fast tests; +1 STC-SEC1C row.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Oleksandr Bezdieniezhnykh
2026-05-12 04:34:39 +03:00
parent 20a39d3d8a
commit b016fd8207
26 changed files with 739 additions and 85 deletions
+5 -12
View File
@@ -8,7 +8,7 @@ import DrawControl from './DrawControl'
import MapPoint from './MapPoint'
import MiniMap from './MiniMap'
import { defaultIcon } from './mapIcons'
import { TILE_URLS } from './types'
import { getTileUrl } from './types'
import type { FlightPoint, CalculatedPointInfo, MapRectangle, ActionMode, MovingPointInfo } from './types'
interface MapEventsProps {
@@ -86,7 +86,6 @@ export default function FlightMap({
}: Props) {
const { t } = useTranslation()
const containerRef = useRef<HTMLDivElement>(null)
const [mapType, setMapType] = useState<'classic' | 'satellite'>('satellite')
const [movingPoint, setMovingPoint] = useState<MovingPointInfo | null>(null)
const [draggablePoints, setDraggablePoints] = useState(points)
const polylineClickRef = useRef(false)
@@ -123,13 +122,14 @@ export default function FlightMap({
<MapContainer center={currentPosition} zoom={15} className="h-full w-full">
<ClickHandler />
<TileLayer
url={mapType === 'classic' ? TILE_URLS.classic : TILE_URLS.satellite}
attribution={mapType === 'classic' ? '&copy; <a href="https://www.openstreetmap.org/copyright">OSM</a>' : 'Satellite'}
url={getTileUrl()}
crossOrigin="use-credentials"
attribution="Satellite"
/>
<MapEvents points={draggablePoints} handlePolylineClick={handlePolylineClick} containerRef={containerRef} onMapMove={onMapMove} />
<SetView center={currentPosition} />
{movingPoint && <MiniMap pointPosition={movingPoint} mapType={mapType} />}
{movingPoint && <MiniMap pointPosition={movingPoint} />}
{draggablePoints.map((point, index) => (
<MapPoint key={point.id}
@@ -171,13 +171,6 @@ export default function FlightMap({
Click and drag on the map to draw a {actionMode === 'workArea' ? 'work area' : 'no-go zone'}
</div>
)}
<button onClick={() => setMapType(m => m === 'classic' ? 'satellite' : 'classic')}
className={`absolute top-2 right-2 z-[400] px-2 py-1 text-xs rounded border ${
mapType === 'satellite' ? 'bg-az-panel border-az-orange text-white' : 'bg-az-panel border-az-border text-az-text'
}`}>
{t('flights.planner.satellite')}
</button>
</div>
)
}
+3 -4
View File
@@ -1,7 +1,7 @@
import { MapContainer, TileLayer, CircleMarker, useMap } from 'react-leaflet'
import { useEffect } from 'react'
import type L from 'leaflet'
import { TILE_URLS } from './types'
import { getTileUrl } from './types'
import type { MovingPointInfo } from './types'
function UpdateCenter({ latlng }: { latlng: L.LatLng }) {
@@ -12,10 +12,9 @@ function UpdateCenter({ latlng }: { latlng: L.LatLng }) {
interface Props {
pointPosition: MovingPointInfo
mapType: 'classic' | 'satellite'
}
export default function MiniMap({ pointPosition, mapType }: Props) {
export default function MiniMap({ pointPosition }: Props) {
return (
<div
className="absolute w-[240px] h-[180px] border border-az-border rounded shadow-lg z-[1000] overflow-hidden pointer-events-none"
@@ -23,7 +22,7 @@ export default function MiniMap({ pointPosition, mapType }: Props) {
>
<MapContainer center={pointPosition.latlng} zoom={18} zoomControl={false}
className="w-full h-full" attributionControl={false}>
<TileLayer url={mapType === 'classic' ? TILE_URLS.classic : TILE_URLS.satellite} />
<TileLayer url={getTileUrl()} crossOrigin="use-credentials" />
<CircleMarker center={pointPosition.latlng} radius={3} color="#fa5252" />
<UpdateCenter latlng={pointPosition.latlng} />
</MapContainer>
@@ -0,0 +1,217 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import type L from 'leaflet'
import { renderWithProviders, screen } from '../../../../tests/helpers/render'
// AZ-498 — self-hosted satellite tiles + drop classic/satellite toggle.
//
// Colocated under src/features/flights/__tests__/ per module-layout's "Tests"
// guidance: keeps the cross-component import surface clean (these tests
// reach into 05_flights internals — `./FlightMap`, `./MiniMap`, `./types` —
// which is intra-component access). Tests/ is reserved for cross-cutting
// black-box suites whose imports must go through public-API barrels.
//
// Covers the spec's fast-profile ACs:
// AC-1 — env-resolved getTileUrl() returns the env var verbatim.
// AC-2 — when the env var is unset, getTileUrl() returns the dev default
// `http://localhost:5100/tiles/{z}/{x}/{y}` (cycle-2 assumption).
// AC-3 — every <TileLayer> the SPA renders sets crossOrigin="use-credentials"
// so the browser attaches the satellite-provider auth cookie.
// AC-4 — the classic/satellite toggle, the `mapType` state, and the
// `MiniMap.Props.mapType` prop are all gone.
//
// Notes:
// - AC-5 is statically enforced by tsc on the new ImportMetaEnv shape +
// the `.env.example` audit; no runtime test needed.
// - AC-6, AC-7 are e2e/contract; AC-8 in the original spec misattributed
// `tile_split_zoom*` (image-annotation surface) — see implementation
// report. AC-9 is enforced by STC-ARCH-01 / STC-ARCH-02.
interface TileLayerProps {
url?: string
crossOrigin?: string
attribution?: string
}
interface MapContainerProps {
children?: React.ReactNode
className?: string
}
vi.mock('react-leaflet', () => ({
MapContainer: ({ children, className }: MapContainerProps) => (
<div data-testid="map-container" className={className}>{children}</div>
),
TileLayer: (props: TileLayerProps) => (
<img
data-testid="tile-layer"
data-tile-url={props.url ?? ''}
data-cross-origin={props.crossOrigin ?? ''}
data-attribution={props.attribution ?? ''}
alt=""
/>
),
Marker: () => null,
Popup: () => null,
Polyline: () => null,
Rectangle: () => null,
CircleMarker: () => null,
useMap: () => ({
on: () => undefined,
off: () => undefined,
setView: () => undefined,
removeLayer: () => undefined,
getCenter: () => ({ lat: 0, lng: 0 }),
invalidateSize: () => undefined,
}),
useMapEvents: () => null,
}))
// Leaflet itself is touched at import time by FlightMap (`L.polyline`,
// `L.Symbol.arrowHead`). Mock the bits the component reaches for so the
// import doesn't blow up under jsdom.
vi.mock('leaflet', () => {
const Lstub = {
polyline: () => ({ addTo: () => Lstub.polyline(), on: () => undefined }),
polylineDecorator: () => ({ addTo: () => undefined }),
Symbol: { arrowHead: () => ({}) },
Icon: { Default: class { mergeOptions() {} } },
Marker: class {},
Layer: class {},
LatLngBounds: class {},
}
return { default: Lstub }
})
vi.mock('leaflet/dist/leaflet.css', () => ({}))
vi.mock('leaflet-polylinedecorator', () => ({}))
vi.mock('../DrawControl', () => ({ default: () => null }))
vi.mock('../MapPoint', () => ({ default: () => null }))
vi.mock('../mapIcons', () => ({ defaultIcon: {} }))
import FlightMap from '../FlightMap'
import MiniMap from '../MiniMap'
import { getTileUrl, DEFAULT_SATELLITE_TILE_URL } from '../types'
const stubLatLng = { lat: 0, lng: 0 } as unknown as L.LatLng
const fixedPosition = { lat: 50, lng: 30 }
const baseFlightMapProps = {
points: [],
calculatedPointInfo: [],
currentPosition: fixedPosition,
rectangles: [],
setRectangles: () => undefined,
rectangleColor: 'red',
actionMode: 'points' as const,
onAddPoint: () => undefined,
onUpdatePoint: () => undefined,
onRemovePoint: () => undefined,
onAltitudeChange: () => undefined,
onMetaChange: () => undefined,
onPolylineClick: () => undefined,
onPositionChange: () => undefined,
onMapMove: () => undefined,
}
describe('AZ-498 — getTileUrl() env resolution', () => {
afterEach(() => {
vi.unstubAllEnvs()
})
it('AC-1: returns the env-set VITE_SATELLITE_TILE_URL verbatim', () => {
// Arrange
vi.stubEnv('VITE_SATELLITE_TILE_URL', 'http://satellite-provider:5100/tiles/{z}/{x}/{y}')
// Assert
expect(getTileUrl()).toBe('http://satellite-provider:5100/tiles/{z}/{x}/{y}')
})
it('AC-2: returns the dev default when VITE_SATELLITE_TILE_URL is unset', () => {
// Arrange
vi.stubEnv('VITE_SATELLITE_TILE_URL', '')
// Assert
expect(getTileUrl()).toBe(DEFAULT_SATELLITE_TILE_URL)
expect(DEFAULT_SATELLITE_TILE_URL).toBe('http://localhost:5100/tiles/{z}/{x}/{y}')
})
it('AC-2: strips trailing slashes off the env-set URL', () => {
// Arrange
vi.stubEnv('VITE_SATELLITE_TILE_URL', 'http://satellite-provider:5100/tiles/{z}/{x}/{y}/')
// Assert
expect(getTileUrl()).toBe('http://satellite-provider:5100/tiles/{z}/{x}/{y}')
})
})
describe('AZ-498 — FlightMap satellite-only TileLayer', () => {
beforeEach(() => {
vi.stubEnv('VITE_SATELLITE_TILE_URL', '')
})
afterEach(() => {
vi.unstubAllEnvs()
})
it('AC-3: <TileLayer> declares crossOrigin="use-credentials"', () => {
// Act
renderWithProviders(<FlightMap {...baseFlightMapProps} />, { withoutAuth: true })
// Assert
const tile = screen.getByTestId('tile-layer')
expect(tile.getAttribute('data-cross-origin')).toBe('use-credentials')
})
it('AC-3: <TileLayer> renders the dev-default URL when env is unset', () => {
// Act
renderWithProviders(<FlightMap {...baseFlightMapProps} />, { withoutAuth: true })
// Assert
const tile = screen.getByTestId('tile-layer')
expect(tile.getAttribute('data-tile-url')).toBe(DEFAULT_SATELLITE_TILE_URL)
})
it('AC-4: the classic/satellite toggle button is gone', () => {
// Act
renderWithProviders(<FlightMap {...baseFlightMapProps} />, { withoutAuth: true })
// Assert
expect(screen.queryByRole('button', { name: /satellite|classic/i })).toBeNull()
// Only one <TileLayer> is mounted (no per-mode branching).
expect(screen.getAllByTestId('tile-layer')).toHaveLength(1)
})
})
describe('AZ-498 — MiniMap satellite-only TileLayer', () => {
beforeEach(() => {
vi.stubEnv('VITE_SATELLITE_TILE_URL', '')
})
afterEach(() => {
vi.unstubAllEnvs()
})
it('AC-3: MiniMap <TileLayer> declares crossOrigin="use-credentials"', () => {
// Act
renderWithProviders(
<MiniMap pointPosition={{ x: 0, y: 0, latlng: stubLatLng }} />,
{ withoutAuth: true },
)
// Assert
const tile = screen.getByTestId('tile-layer')
expect(tile.getAttribute('data-cross-origin')).toBe('use-credentials')
})
it('AC-4: MiniMap mounts with only `pointPosition` prop (no `mapType`)', () => {
// Act — explicitly omit mapType; if MiniMap still required it, TS would
// error at compile time. The runtime render also confirms the component
// mounts with just the position prop.
renderWithProviders(
<MiniMap pointPosition={{ x: 0, y: 0, latlng: stubLatLng }} />,
{ withoutAuth: true },
)
// Assert
expect(screen.getByTestId('tile-layer')).toBeInTheDocument()
})
})
+14 -9
View File
@@ -52,13 +52,18 @@ export const PURPOSES = [
export const COORDINATE_PRECISION = 8
const trimTrailingSlash = (s: string) => s.replace(/\/+$/, '')
// AZ-498 — single self-hosted satellite tile URL. The previous classic/satellite
// pair (OSM + Esri) was retired so the SPA only consumes the suite's own
// satellite-provider service. Production builds MUST set VITE_SATELLITE_TILE_URL
// to the same-origin nginx path (e.g. `/tiles/{z}/{x}/{y}`); the dev default
// targets the satellite-provider container on its conventional dev port.
//
// Read via a function (mirrors `getOwmBaseUrl` in flightPlanUtils.ts) so tests
// can stub `import.meta.env` per-case without module-reload tricks.
export const DEFAULT_SATELLITE_TILE_URL = 'http://localhost:5100/tiles/{z}/{x}/{y}'
export const TILE_URLS = {
classic:
trimTrailingSlash(import.meta.env.VITE_OSM_TILE_URL ?? '') ||
'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
satellite:
trimTrailingSlash(import.meta.env.VITE_ESRI_TILE_URL ?? '') ||
'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
} as const
export function getTileUrl(): string {
const raw = import.meta.env.VITE_SATELLITE_TILE_URL
if (!raw) return DEFAULT_SATELLITE_TILE_URL
return raw.replace(/\/+$/, '')
}
-1
View File
@@ -77,7 +77,6 @@
},
"invalidJson": "Invalid JSON format",
"editJsonHint": "Edit the JSON data as needed.",
"satellite": "Satellite",
"cameraFov": "Camera FOV / Length / Field",
"cameraFovPlaceholder": "FOV parameters",
"commAddr": "Communication Addr / Port",
-1
View File
@@ -77,7 +77,6 @@
},
"invalidJson": "Невірний JSON формат",
"editJsonHint": "Відредагуйте JSON дані за потреби.",
"satellite": "Супутник",
"cameraFov": "Камера FOV / Фокус",
"cameraFovPlaceholder": "Параметри FOV",
"commAddr": "Адреса / Порт",
+1 -2
View File
@@ -7,8 +7,7 @@ interface ImportMetaEnv {
readonly VITE_API_BASE_URL?: string
readonly VITE_OWM_API_KEY?: string
readonly VITE_OWM_BASE_URL?: string
readonly VITE_OSM_TILE_URL?: string
readonly VITE_ESRI_TILE_URL?: string
readonly VITE_SATELLITE_TILE_URL?: string
}
interface ImportMeta {