feat: закрыть остаточный backlog

- keyset-пагинация аудита с курсором (occurred_at, id) и UI «Показать ещё»
- пакетный /api/v1/labs/details и переход progress на один запрос
- абсолютный потолок сессии 30 дней (identity V12 + web), очистка refresh
- админская инвентаризация legacy-активов (/admin/legacy-assets, скрипт)
- E2E в Chromium/Firefox/WebKit (18/18), CI preflight
- LDD: 51 claim, 346 тестов, docs и handoff обновлены
This commit is contained in:
2026-10-28 19:29:23 +03:00
parent d45137220c
commit 1e1e9ae0e4
64 changed files with 2124 additions and 372 deletions

81
scripts/legacy-assets.sh Executable file
View File

@@ -0,0 +1,81 @@
#!/usr/bin/env bash
# Инвентаризация legacy-активов (файлов без связи со статьёй) на работающем стенде.
# Пример:
# LMS_BASE_URL=https://lms.example.ru LMS_ADMIN_LOGIN=admin LMS_ADMIN_PASSWORD=... \
# ./scripts/legacy-assets.sh
set -euo pipefail
base_url="${LMS_BASE_URL:-http://localhost:8080}"
admin_login="${LMS_ADMIN_LOGIN:-}"
admin_password="${LMS_ADMIN_PASSWORD:-}"
if [[ -z "$admin_login" || -z "$admin_password" ]]; then
echo "Задайте LMS_ADMIN_LOGIN и LMS_ADMIN_PASSWORD." >&2
echo "Пример: LMS_BASE_URL=https://lms.example.ru LMS_ADMIN_LOGIN=admin LMS_ADMIN_PASSWORD=... $0" >&2
exit 2
fi
LMS_BASE_URL="${base_url%/}" \
LMS_ADMIN_LOGIN="$admin_login" \
LMS_ADMIN_PASSWORD="$admin_password" \
python3 - <<'PY'
import json
import os
import sys
import urllib.error
import urllib.request
base_url = os.environ["LMS_BASE_URL"]
admin_login = os.environ["LMS_ADMIN_LOGIN"]
admin_password = os.environ["LMS_ADMIN_PASSWORD"]
def request(method, path, payload=None, token=None):
body = None if payload is None else json.dumps(payload).encode("utf-8")
headers = {"Accept": "application/json"}
if body is not None:
headers["Content-Type"] = "application/json"
if token:
headers["Authorization"] = "Bearer " + token
req = urllib.request.Request(base_url + path, data=body, headers=headers, method=method)
try:
return urllib.request.urlopen(req, timeout=30)
except urllib.error.HTTPError as exc:
detail = exc.read().decode("utf-8", "replace").strip()
message = f"{method} {path}: HTTP {exc.code}"
if detail:
message += f": {detail}"
print(message, file=sys.stderr)
raise SystemExit(1)
except urllib.error.URLError as exc:
print(f"Не удалось подключиться к {base_url}: {exc.reason}", file=sys.stderr)
raise SystemExit(1)
with request("POST", "/api/v1/auth/login", {"login": admin_login, "password": admin_password}) as response:
login_body = response.read().decode("utf-8")
try:
token = json.loads(login_body)["token"]
except (ValueError, KeyError, TypeError):
print("Ответ /api/v1/auth/login не содержит token.", file=sys.stderr)
raise SystemExit(1)
with request("GET", "/api/v1/content/legacy-assets", token=token) as response:
try:
assets = json.load(response)
except ValueError:
print("Ответ /api/v1/content/legacy-assets не является JSON.", file=sys.stderr)
raise SystemExit(1)
if not isinstance(assets, list):
print("Неожиданный формат /api/v1/content/legacy-assets: ожидался список.", file=sys.stderr)
raise SystemExit(1)
for asset in assets:
if not isinstance(asset, str):
print(f"Пропущен нестроковый элемент: {asset!r}", file=sys.stderr)
continue
print(asset)
print(f"Всего legacy-активов: {len(assets)}", file=sys.stderr)
PY