Files
LMS/docs/16-stage6-report.md
Vladislav Kopnin 685916c4bd ci: перевести merge gate на Gitea + Woodpecker
- .woodpecker/check.yaml, smoke.yaml, release.yaml
- local-backend агент: Java 21, Docker Compose v2, Python 3, Node/Playwright
- concurrency lms-ci, чтобы smoke/release не занимали порты одновременно
- deploy/WOODPECKER.md: server, OAuth Gitea, systemd-агент, security note
- удалён .gitlab-ci.yml
2026-10-29 12:01:44 +03:00

33 KiB
Raw Blame History

16. Отчёт этапа 6: проверка, hardening и передача

Дата: 2026-10-27, обновлено 2026-10-28 после закрытия продуктовых рисков. Ветка: feature/groups-courses. Статус: этап 6 закрыт. Продуктовые риски (legacy-активы, срок жизни JWT, сессии, пагинация аудита и N+1 имён групп) закрыты; оставшиеся ограничения перечислены в разделе 4 и не блокируют передачу.

1. Итоговый объём этапов 0–6

Этап Что реализовано Отчёт
0 Схема и контракты: identity V4–V13 (V10 — аудит, V11 — refresh, V12 — absolute, V13 — keyset-индексы), academic V6–V12, progress V13; preflight-скрипты; ADR; контракт профиля студента курса 10
1 Роли ADMIN/TEACHER/STUDENT, группы-когорты и парсер имени, статусы студентов и аккаунтов, membership_history, импорт v2 11
2 Курсы, course_no для браузерных URL, история преподавателей и групп, bootstrap курса C, привязка лабораторных к курсу, копирование лабораторных, админ-UI 12
3 Аккаунты преподавателей, курсовая область видимости, внутренние API identity с LMS_INTERNAL_TOKEN, кабинет преподавателя, профиль студента курса и скачивание всех попыток 13
4 Повышение (dry-run/apply/rollback, планировщик 31 августа МСК), академотпуск, отчисление, выпуск, архив/восстановление, усиленное удаление, блокировка входа 14
5 Курсовые материалы /learn/course/{course_no}/..., публичные глобальные материалы ALL, скрытые материалы для преподавателя, перепроверка статуса web-сессии 15
6 Права преподавателя на запись (лабораторные, ведомость, группы, материалы), центральный аудит identity.audit_events, CI merge gate, hardening и deploy-runbook этот отчёт

Ключевые инварианты, действующие после этапов 0–6:

  • все лабораторные принадлежат курсу; существующие лабораторные, главы и активные группы перенесены в курс C;
  • API работает с UUID курса, браузерные URL — с course_no;
  • преподаватель видит только активные назначенные курсы; студент — только курсы активной группы; админ — все курсы;
  • студент видит только опубликованные оценки и комментарии;
  • активы наследуют видимость статьи → раздела → главы: курсовые требуют доступа к курсу, глобальные — аудитории, а файлы без связи со статьёй по умолчанию недоступны (404) всем, кроме админа; админ перечисляет их через GET /api/v1/content/legacy-assets;
  • access-токен живёт 15 минут, refresh — 7 дней с ротацией и отзывом; web-сессия скользящая на 7 дней, но не дольше абсолютного потолка 30 дней, и сама обновляет access-токен; истёкшие refresh-токены очищаются ночью;
  • аудит продолжается keyset-курсором (occurred_at, id), а детали всех лабораторных курса отдаются одним пакетным запросом без N+1;
  • преподаватель своего курса может писать: лабораторные/уровни/варианты, ведомость, привязка групп и авторские материалы курса; чужие курс, лаба, студент и группа отклоняются на сервисах;
  • вход закрыт для отчисленных, выпущенных и архивных студентов, а также для архивных аккаунтов; web дополнительно перепроверяет статус;
  • повышение и жизненный цикл пишут историю; применение повышения идемпотентно;
  • чувствительные действия пишутся в центральный identity.audit_events, админ смотрит журнал на /admin/audit;
  • CI на каждый push/MR прогоняет test ktlintCheck detekt.

2. Проверка

2.1. Сборка, тесты и линтеры

Выполнено 2026-10-27:

./gradlew --no-daemon test ktlintCheck detekt

Результат: BUILD SUCCESSFUL, код возврата 0, все модули зелёные.

Для получения актуальных счётчиков тесты запущены принудительно:

./gradlew test --rerun-tasks
Модуль Тестов Failures Errors Skipped
identity-service 98 0 0 0
academic-service 99 0 0 0
progress-service 36 0 0 0
web 106 0 0 0
gateway 2 0 0 0
markdown 5 0 0 0
Итого на момент этапа 6 346 0 0 0

После закрытия рисков актуальный LDD-baseline — 346 тестов, 0 failures (см. раздел 7). ktlintCheck и detekt прошли без замечаний. У gateway теперь есть unit-тесты публичных auth-путей и 401; маршрутизация gateway дополнительно проверена конфигурацией и security-матрицей ниже. Тот же набор команд выполняет .woodpecker/ (Gitea + Woodpecker 3.x); на каждый push/MR добавлен smoke-workflow, полный release обязателен для тегов.

2.2. Upgrade-тест миграций

Проверка выполнена на одноразовом PostgreSQL 18 (Testcontainers/контейнер, не production-БД):

  1. Развёрнута схема master: identity V1–V3, academic V1–V5, progress V1–V9, V11, V12.
  2. Загружены синтетические данные: администратор и два студента, две группы (ПИ-11БО, ПИ-21БО), зачисления, семестр, лабораторная, глава/раздел/ статья/актив, оценка, комментарий и попытка сдачи.
  3. Поверх master применены миграции ветки:
    • identity V4–V13;
    • academic V6–V12;
    • progress V13.
  4. Выполнены postflight-запросы и повторное применение V13.

Результат: успешно, без ошибок.

  • identity: группы разобраны в stream/course/number/degree/enrollment_year (ПИ-11БО → поток ПИ, курс 1, номер 1, БО, год набора 2026; ПИ-21БО → курс 2, год 2025); статус ACTIVE; созданы promotion_runs, promotion_run_items; зачисления перенесены в membership_history действием ENROLL; добавлен accounts.status; V10 создаёт identity.audit_events без внешних ключей и индексы по occurred_at/action; V11 создаёт identity.refresh_tokens (SHA-256-хеш, expires_at, revoked_at, replaced_by, индексы по аккаунту и сроку); V12 добавляет absolute_expires_at с бэкфиллом и NOT NULL; V13 создаёт композитные индексы (occurred_at DESC, id DESC) и (action, occurred_at DESC, id DESC) под keyset-пагинацию.
  • academic: добавлены labs.course_id, chapters.course_id/audience, индексы labs_number_course_uidx, chapters_slug_global_uidx, chapters_slug_course_uidx, article_assets_article_idx, courses_title_lower_uidx. V12__asset_links_backfill.sql восстанавливает связи article_assets из markdown-ссылок /api/v1/content/assets/{name}; файлы без ссылок остаются несвязанными. Курс C миграциями не создаётся — его создаёт bootstrap после входа админа, поэтому labs_without_course до bootstrap ожидаемо больше нуля.
  • progress: grade_comments.author_account_id и lab_grades.updated_by_account_id созданы как nullable uuid; индекс lab_grades_lab_idx существует и валиден; старый комментарий остался без автора, как и задумано.
  • V13 идемпотентна: повторное применение V13 (дважды) завершилось сообщениями already exists, skipping и не изменило схему. Это же проверяет ProfileAuthorsMigrationTest на Testcontainers.
  • progress V10 отсутствует: в ветке нет progress-service/.../V10__profile_authors.sql; в истории прогресса применяются V11, V12, затем V13. У academic V10 — это другой файл, V10__course_title_unique.sql (уникальный индекс названия курса).
  • Переход forward-only: после V13 откат на сборку без неё не пройдёт валидацию Flyway; out-of-order включать нельзя.

2.3. Security-проверки

Проверено автоматическими тестами (актуальный прогон — 346 тестов, 0 failures) и разбором конфигурации gateway. Запись преподавателя дополнительно покрыта negative-тестами «свой/чужой курс».

Сценарий Ожидание Факт
Аноним → курс/материалы курса нет доступа 401 на /api/v1/courses/{id}/content/tree; /api/v1/courses/by-no/{no} — 401
Аноним → публичные материалы только глобальные ALL публичное дерево содержит только глобальные главы с audience = ALL; курсовая глава через /api/v1/content/... — 404
Аноним → актив глобальной ALL-статьи публично 200
Аноним → курсовой/STUDENTS/TEACHERS актив 404 404, существование файла не раскрывается
Студент своего курса → актив опубликованной статьи доступ 200; скрытый курсовой актив — 404
Студент/преподаватель чужого курса → курсовой актив 403 403
Преподаватель своего курса → скрытый курсовой актив доступ 200; админ видит любой актив
Глобальный STUDENTS-актив → преподаватель 403 403; студент получает 200
Legacy-актив без связи со статьёй 404 для анонима/студента/преподавателя, файл доступен только админу 404 всем, кроме ADMIN; GET /api/v1/content/legacy-assets под ROLE_ADMIN перечисляет несвязанные файлы
Access/refresh короткий access + ротация refresh access JWT 15 минут (LMS_ACCESS_TOKEN_TTL), refresh 7 дней (LMS_REFRESH_TOKEN_TTL), абсолютный потолок сессии 30 дней (LMS_SESSION_ABSOLUTE_TIMEOUT); POST /api/v1/auth/refresh ротирует пару, не продлевая absolute_expires_at; повторное использование старого токена отзывает все сессии аккаунта; истёкшие токены чистит ночная задача
Logout / смена пароля сессии отзываются POST /api/v1/auth/logout отзывает refresh; смена пароля — revokeAll
Архив/отчисление/закрытая группа → refresh обновление запрещено ensureSessionAllowed при ротации: EXPELLED, GRADUATED, ARCHIVED, архивный аккаунт и GRADUATED/ARCHIVED группа дают 401
Web-сессия 7 дней sliding, без повторного входа, быстрый отзыв cookie LMS_SESSION, Max-Age 7 дней (LMS_SESSION_TIMEOUT), HttpOnly/SameSite=Lax; access обновляется за 2 минуты до истечения, cookie перевыпускается на каждом ответе; статус перепроверяется не реже чем раз в 45 секунд, active=false или 401/403 инвалидируют сессию, недоступность identity не разлогинивает всех; выход отзывает refresh
Админ → курс, профиль, скачивание полный доступ доступ к любому курсу и студенту, скачивание любой попытки курса
Преподаватель своего курса → чтение курс, группы, профиль, материалы видит опубликованные и скрытые материалы, всех студентов активной группы курса, неопубликованные оценки и все попытки; GET /api/v1/courses/{id}/groups отдаёт привязанные группы с именами одним вызовом (без N+1)
Преподаватель своего курса → запись лабы/уровни/варианты, ведомость, группы, материалы оценки/комментарии, публикация, уровни/варианты и раздача, защиты (код/reset), привязка групп, авторство материалов; проверки в progress CourseScopeService и academic course/content authoring
Преподаватель чужого курса 403 403 на чтение и запись: материалы, курс по course_no, состав группы, профиль, лабы, оценки, группы
Аудит → админ чтение журнала GET /api/v1/audit?limit=&action=&cursor= (offset page — для совместимости) и /admin/audit доступны только ADMIN; в UI — keyset-переход «Показать ещё», битый курсор — 400
Аудит → запись из сервисов только по токену POST /api/v1/internal/audit с X-Internal-Token; без/неверный токен — 403
Студент → свой профиль только своё и опубликованное скрытые оценки/комментарии отсутствуют; чужой studentId — 403
Студент → чужой курс/группа 403 доступ только к курсам активной группы; архивный курс — 403
Чужая сдача / лаба другого курса 403 скачивание отклоняется; своя попытка курса скачивается ZIP
Внутренние API identity только по токену неверный/пустой X-Internal-Token — 403, сравнение constant-time; неизвестный id — 404
Gateway внутренние пути не публикуются маршрутов на /api/v1/internal/** нет; gateway требует JWT для всего, кроме /actuator, входа/setup/JWKS/refresh/logout и публичного /api/v1/content/**
Gateway → заголовки личности клиент не подменяет X-Account-Id/Login/Role удаляются и проставляются только после проверки JWT

Остаточные ограничения перечислены в разделе 4: ручная инвентаризация legacy-активов на production, сдачи по выбранной лабораторной в ведомости, непроверенный первый CI-прогон и отсутствие визуальной регрессии. Продуктовые риски legacy-активов, срока access-токена, пагинации, N+1 деталей лабораторных, абсолютного лимита сессии и ведущих нулей закрыты или сняты как не относящиеся к требованиям.

3. Чек-лист деплоя

3.1. Перед деплоем

  • Backup обязателен. Выполнить ./deploy/backup.sh backup и ./deploy/backup.sh verify <dir>. ./deploy/deploy.sh up сам вызывает backup.sh backup --if-running; при ошибке backup деплой прерывается. Backup на том же сервере не считается единственной защитой.
  • В .env.prod задан LMS_INTERNAL_TOKEN — длинная случайная строка (openssl rand -hex 32), одинаковая для identity, academic и progress. Значение по умолчанию dev-internal-token в production не использовать. Там же задан LMS_DB_PASSWORD; .env.prod не коммитится.
  • Проверить, что на сервере нет незакоммиченных изменений и выполнен git pull нужной ветки/тега.
  • CI зелёный. .woodpecker/ (Gitea + Woodpecker 3.x) на каждом push/MR выполняет workflow check (test ktlintCheck detekt evidenceCheck) и smoke (scripts/ldd-smoke.sh: полный стек, health, вход админа, один курс). Полный release обязателен для тегов и запускается вручную для веток. Агент — local backend с Java 21, Docker Compose v2, Python 3, Node и Chromium/Firefox/WebKit; установка — deploy/WOODPECKER.md.
  • Заданы сроки сессии: LMS_ACCESS_TOKEN_TTL=15m, LMS_REFRESH_TOKEN_TTL=7d, LMS_SESSION_TIMEOUT=7d, LMS_SESSION_ABSOLUTE_TIMEOUT=30d, LMS_REFRESH_TOKEN_RETENTION=30d (значения по умолчанию подходят; менять только осознанно). Очистка refresh запускается по LMS_REFRESH_TOKEN_CLEANUP_CRON (по умолчанию 03:30).

3.2. Порядок выкладки

  1. Flyway. ./deploy/deploy.sh up собирает образы и запускает postgres → identity → academic → progress → gateway → web. Миграции применяются автоматически при старте сервисов: identity V4–V13 (V10 — identity.audit_events, V11 — identity.refresh_tokens, V12 — absolute_expires_at, V13 — keyset-индексы аудита), academic V6–V12, progress V13. Отдельный ручной запуск Flyway не нужен.
  2. Bootstrap курса C. После входа админа открыть /admin/courses. Пока курсов нет, показан блок первичной настройки. Bootstrap идемпотентно создаёт/находит курс «Промышленное программирование на языке C», привязывает активные группы, переносит лабораторные и главы и, если лабораторных без курса не осталось, выполняет ALTER TABLE academic.labs ALTER COLUMN course_id SET NOT NULL. Это post-bootstrap шаг в рантайме, а не Flyway-миграция. Повторный запуск безопасен. Если в БД уже есть курс C, bootstrap не требуется.
  3. Postflight. Выполнить psql -h localhost -U lms -d lms -f deploy/preflight/groups-courses-post.sql и проверить:
    • labs_without_course = 0;
    • chapters_without_course = 0 (если все текущие материалы курсовые);
    • ровно один курс C;
    • comments_without_author соответствует старым комментариям;
    • группы без полного разбора отсутствуют;
    • таблица identity.audit_events существует (V10);
    • таблица identity.refresh_tokens существует (V11);
    • у refresh_tokens есть absolute_expires_at NOT NULL (V12);
    • индекс audit_events_occurred_id_idx существует (V13).
  4. V10 → V13 — только dev/staging. На production/master записи progress V10__profile_authors.sql нет, ничего удалять не нужно. Не путать с academic V10__course_title_unique.sql — он относится к другой схеме и должен применяться как обычно. Если БД когда-либо применяла старую feature-миграцию V10:
    • сделать backup;
    • проверить deploy/preflight/progress-v10-v13.sql (columns_ok и index_ok должны быть true);
    • удалить только строку истории V10 (success=true) через flyway repair или точечный DELETE из примера в скрипте; объекты схемы не удалять;
    • при success=false сначала убедиться, что частичные объекты откатились;
    • не переставлять V11/V12 и не включать out-of-order. После V13 переход forward-only.

3.3. Smoke после деплоя

  • docker compose -f docker-compose.prod.yml --env-file .env.prod ps — все контейнеры running; curl -fsS http://127.0.0.1:8090/actuator/health отдаёт UP.
  • Админ: вход, /admin/courses (курс C и счётчики), /admin/groups, /admin/labs, /admin/teachers, /admin/promotion.
  • Преподаватель: вход, /teacher, карточка своего курса (имена групп, привязка/отвязка группы по available-groups), ведомость /admin/groups/{groupId}?courseId=... без админских контролов, оценка и публикация, уровень/вариант и раздача, защита (код/reset), профиль студента, скачивание попытки и ZIP группы, авторство материалов /teacher/courses/{id}/content.
  • Студент: вход, дашборд, лабораторная, сдача/история, курсовые материалы; видны только опубликованные оценки и комментарии.
  • Материалы курса: админ и преподаватель видят скрытые статьи, студент — только опубликованные; чужой курс даёт 403, аноним уходит на /login.
  • Активы: картинки курсовой статьи открываются у студента своего курса и преподавателя; чужой курс — 403, скрытая картинка у студента — 404, публичная ALL — без входа; файл без привязки к статье — 404 для всех, кроме админа. Админ открывает /admin/legacy-assets (или GET /api/v1/content/legacy-assets, или scripts/legacy-assets.sh) и при необходимости привязывает или удаляет файлы.
  • Копирование лабораторной: админ копирует лабораторную в другой курс; создаётся черновик с уровнями и вариантами; оценки, сдачи, защиты и назначения не копируются; занятый номер даёт 409.
  • Профиль: преподаватель своего курса видит сводку, комментарии, все попытки и скачивает ZIP; чужой студент/курс — 403.
  • Повышение: /admin/promotion строит dry-run за год, данные не меняются; применение — только после проверки плана; откат доступен для завершённого запуска. На production не применять план «на всякий случай».
  • Аудит: /admin/audit открывается у админа, показывает события (например, PROFILE_VIEW, SUBMISSION_DOWNLOAD, GRADE_SET, CONTENT_*, TEACHER_*) с фильтром по действию и временем МСК; «Показать ещё» переходит по keyset-курсору (cursor), «В начало» возвращает к первой странице; преподаватель/студент получают 403.
  • Сессия: после входа cookie LMS_SESSION живёт 7 дней, но не дольше 30 дней с момента входа; при работе дольше 15 минут страница продолжает работать без повторного входа (access обновляется автоматически). Выход и смена пароля требуют повторного входа; вход отчисленного/архивного студента и refresh закрытой группы отклоняются.
  • Проверить логи web, gateway, identity, academic, progress на ERROR/WARN после smoke.

4. Известные пробелы и follow-up

  1. Старый формат импорта не поддерживается. Импорт принимает только шаблон v2 (поток/курс/номер/степень/год/ФИО/№/база). Ограничение зафиксировано в 11. Номер группы — числовой, поэтому отдельного требования к ведущим нулям нет.
  2. Инвентаризация legacy-активов на реальном хранилище. UI /admin/legacy-assets, API и scripts/legacy-assets.sh готовы; остаётся прогнать их на production/staging и привязать или удалить найденные файлы. Это гигиена хранилища, а не открытый риск доступа: обычные пользователи уже получают 404.
  3. Ведомость запрашивает сдачи по выбранной лабораторной. Детали всех лабораторных курса теперь пакетные (GET /api/v1/labs/details), но ведомость при открытии конкретной лабораторной делает отдельный запрос сдач. Это не N+1 по студентам и не блокер.
  4. Browser E2E без визуальной регрессии. 6 сценариев × Chromium/Firefox/ WebKit = 18/18, но нет screenshot-diff и полного набора мобильных страниц. На Fedora WebKit запускается через Docker-образ Playwright, потому что системных Ubuntu-библиотек нет.
  5. Первый реальный CI-прогон не подтверждён. Конфигурация .woodpecker/, smoke и scripts/ci-preflight.sh готовы, но Woodpecker-сервер и агент на сервере ещё нужно развернуть и проверить первый push-MR pipeline и tag-job.
  6. Внешняя интеграция «Контингент» исключена из плана. Отдельного anti-corruption слоя, синхронизации и внешних идентификаторов не будет без нового ТЗ и доступа к API.
  7. Mutation testing не 100%. Остаются эквивалентные мутанты и Kotlin/Spring-специфичные мутации; PIT harness — JUnit 4.
  8. Нагрузочный smoke — не SLA. Одна машина, синтетические данные; 125 чтений и p95 < 1 с — ориентир, а не гарантия production.

5. Рекомендуемые следующие шаги

Приоритет — сверху вниз:

  1. Прогнать инвентаризацию legacy-активов на production/staging через /admin/legacy-assets или scripts/legacy-assets.sh, привязать нужные файлы к статьям, остальные удалить.
  2. Развернуть Woodpecker и проверить CI. Поднять server и local-backend агент по deploy/WOODPECKER.md, включить репозиторий, дождаться зелёных check/smoke на push-MR и release на теге.
  3. Дальнейшее расширение E2E — визуальная регрессия и остальные мобильные страницы, если UI продолжит расти.
  4. Пакетные сдачи ведомости, если появятся курсы, где открытие ведомости станет заметно медленным.

6. Итог

Этап 6 закрыт: права преподавателя на запись, аудит, CI, security-матрица, upgrade-тест миграций, видимость активов и deploy-runbook проверены. Продуктовые риски закрыты: legacy-активы secure-by-default, access/refresh и web-сессия с отзывом и абсолютным потолком, keyset-пагинация аудита, пакетные детали лабораторных и bulk-выдача групп. Оставшиеся ограничения (ручная инвентаризация на production, сдачи по выбранной лабораторной, непроверенный первый CI-прогон) задокументированы в разделе 4 и не блокируют передачу.

7. LDD baseline

После этапа 6 и закрытия рисков текущая функциональность проверена через LDD-контур: реестр docs/evidence/claims.json (51 claims, 25 P0 / 26 P1), gate ./gradlew evidenceCheck, upgrade-скрипт, runtime-smoke, smoke-workflow, mutation testing и браузерный Playwright E2E. Результат: 51/51 claims proven, P0 gaps = 0, 346 тестов, 0 failures; mutation strength CourseAccessService 75%, PromotionPlanner 69%; browser E2E 18/18 (6 сценариев × Chromium/Firefox/WebKit); конкурентные apply/rollback и масштабный нагрузочный smoke (100 групп / 1000 студентов, 125 параллельных чтений, 0 ошибок, p95 < 1 с) зелёные. Workflow smoke выполняется на каждом push/MR, полный release обязателен для тегов и доступен вручную для веток. E2E нашёл и закрыл реальный дефект Jackson 3/Kotlin defaults. Подробности и остаточные ограничения — docs/evidence/baseline-2026-10-27.md.