Реализация ET-011: кнопка «Скачать GPX» в popup публичного GPS-трека и
новый эндпоинт GET /api/gps-tracks/{track_id}/download (GPX 1.1 +
Content-Disposition с UTF-8 именем по RFC 5987). Реэкспорт защищён
per-source флагом `download_allowed` в `config/gps_sources.yaml`
(default-deny, MVP whitelist = `osm`).
Backend:
- `src/api/gps_tracks/export.py` — чистый stdlib-builder GPX 1.1
(`build_gpx`) + санитизация имени файла (`safe_filename`, RFC 5987).
- `src/api/gps_tracks/endpoint.py` — новый route с проверками
400 / 403 / 404 / 413; cap 200 000 точек (REQ-NF-02).
- `src/api/gps_tracks/config.py` — `load_download_allowed_sources()`
читает YAML, default-deny при отсутствии поля; fallback на `{"osm"}`
при отсутствии конфига.
- `src/api/main.py` — пробрасывает `GPS_SOURCES_CONFIG_PATH` в router.
Frontend:
- `src/web/gps_tracks.js` — кнопка в `_renderTrackPopupHtml`,
обработчик `_downloadPublicTrack` (fetch + Blob + a.download — тот же
паттерн, что в `app.js::downloadGPX`, R-1 митигирован), парсер
`_parseFilenameFromCD` для RFC 5987, маппинг ошибок
`_handleDownloadError` (403/404/413/5xx → showToast).
- `src/web/app.css` — стиль кнопки, 32×32 CSS px (REQ-NF-04).
Тесты:
- 13 unit для GPX-builder (UT-01/02/03/05; XSD-валидация против
`tests/fixtures/gpx-1.1/gpx.xsd`).
- 10 unit для `safe_filename` (UT-04).
- 11 integration для download-эндпоинта (IT-01..08 +
ANY-rule license check + default-deny без конфига).
ADR-014 (gpx-download-endpoint), ADR-015 (source-redistribution-policy).
Refs: ET-011
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
96 lines
3.7 KiB
Python
96 lines
3.7 KiB
Python
"""Unit-тесты для ET-011 sanitize/safe_filename (UT-04, REQ-F-04)."""
|
||
from __future__ import annotations
|
||
|
||
import urllib.parse
|
||
|
||
from src.api.gps_tracks.export import safe_filename
|
||
|
||
|
||
def test_ut04_cyrillic_utf8():
|
||
"""UT-04: кириллическое имя → ascii-fallback пустой и читается из utf-8."""
|
||
name = "По грязи к Чёрному озеру"
|
||
ascii_fb, utf8_quoted = safe_filename(name, 42)
|
||
|
||
# ascii_fallback содержит только ASCII (после санитизации
|
||
# нелатинские символы стали '_'), длина ≤ 80
|
||
assert all(ord(c) < 128 for c in ascii_fb)
|
||
assert len(ascii_fb) <= 80
|
||
|
||
# decoded utf-8 совпадает с исходным именем (до триммирования по 80 байтам)
|
||
decoded = urllib.parse.unquote(utf8_quoted, encoding="utf-8")
|
||
assert decoded == name
|
||
|
||
|
||
def test_ut04_forbidden_chars_replaced():
|
||
"""UT-04: запрещённые ФС-символы → '_'."""
|
||
name = 'Trail/with:bad*chars?"<>|'
|
||
ascii_fb, _ = safe_filename(name, 1)
|
||
for ch in '/\\:*?"<>|':
|
||
assert ch not in ascii_fb
|
||
# должно быть несколько подчёркиваний (хотя бы один на запрещённый символ)
|
||
assert "_" in ascii_fb
|
||
|
||
|
||
def test_ut04_empty_name_fallback_track_id():
|
||
"""UT-04: пустое имя → 'track-<id>'."""
|
||
ascii_fb, utf8_q = safe_filename("", 42)
|
||
assert ascii_fb == "track-42"
|
||
assert urllib.parse.unquote(utf8_q) == "track-42"
|
||
|
||
|
||
def test_ut04_none_name_fallback_track_id():
|
||
ascii_fb, utf8_q = safe_filename(None, 7)
|
||
assert ascii_fb == "track-7"
|
||
assert urllib.parse.unquote(utf8_q) == "track-7"
|
||
|
||
|
||
def test_ut04_truncated_to_80_bytes():
|
||
"""UT-04: длинное ASCII-имя триммится до 80 байт."""
|
||
name = "X" * 200
|
||
ascii_fb, utf8_q = safe_filename(name, 1)
|
||
assert len(ascii_fb.encode("utf-8")) <= 80
|
||
# utf8_q после percent-decoding тоже не должен превышать лимит
|
||
decoded = urllib.parse.unquote(utf8_q, encoding="utf-8")
|
||
assert len(decoded.encode("utf-8")) <= 80
|
||
|
||
|
||
def test_ut04_truncated_utf8_no_broken_codepoints():
|
||
"""UT-04: триммирование multibyte-строки не ломает code-point."""
|
||
# 200 русских букв = 400 байт UTF-8; триммим до 80 байт → ~40 букв
|
||
name = "Я" * 200
|
||
ascii_fb, utf8_q = safe_filename(name, 1)
|
||
decoded = urllib.parse.unquote(utf8_q, encoding="utf-8")
|
||
# должно успешно декодироваться
|
||
assert len(decoded) > 0
|
||
assert len(decoded.encode("utf-8")) <= 80
|
||
|
||
|
||
def test_ut04_only_forbidden_chars_fallback():
|
||
"""UT-04: имя из одних запрещённых символов после strip → fallback."""
|
||
ascii_fb, utf8_q = safe_filename("...", 5)
|
||
# точки страйпятся, остаётся пустота → fallback
|
||
assert ascii_fb == "track-5"
|
||
|
||
|
||
def test_ut04_whitespace_only_fallback():
|
||
ascii_fb, _ = safe_filename(" ", 8)
|
||
assert ascii_fb == "track-8"
|
||
|
||
|
||
def test_ut04_control_chars_replaced():
|
||
"""Управляющие символы (0x00..0x1F, 0x7F) → '_'."""
|
||
name = "abc\x00\x01\x1fdef\x7f"
|
||
ascii_fb, _ = safe_filename(name, 1)
|
||
assert "\x00" not in ascii_fb
|
||
assert "\x1f" not in ascii_fb
|
||
assert "\x7f" not in ascii_fb
|
||
assert "abc" in ascii_fb and "def" in ascii_fb
|
||
|
||
|
||
def test_ut04_ascii_clean_kept_as_is():
|
||
"""ASCII-чистое имя сохраняется в ascii-fallback."""
|
||
name = "OSM Trail 42"
|
||
ascii_fb, utf8_q = safe_filename(name, 1)
|
||
assert ascii_fb == "OSM Trail 42"
|
||
assert urllib.parse.unquote(utf8_q) == "OSM Trail 42"
|