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

View File

@@ -10,7 +10,7 @@
| Этап | Что реализовано | Отчёт |
|---|---|---|
| 0 | Схема и контракты: identity `V4–V11` (`V10` — аудит, `V11` — refresh-токены), academic `V6–V12`, progress `V13`; preflight-скрипты; ADR; контракт профиля студента курса | [10](10-stage0-report.md) |
| 0 | Схема и контракты: identity `V4–V13` (`V10` — аудит, `V11` — refresh, `V12` — absolute, `V13` — keyset-индексы), academic `V6–V12`, progress `V13`; preflight-скрипты; ADR; контракт профиля студента курса | [10](10-stage0-report.md) |
| 1 | Роли `ADMIN/TEACHER/STUDENT`, группы-когорты и парсер имени, статусы студентов и аккаунтов, `membership_history`, импорт v2 | [11](11-stage1-report.md) |
| 2 | Курсы, `course_no` для браузерных URL, история преподавателей и групп, bootstrap курса C, привязка лабораторных к курсу, копирование лабораторных, админ-UI | [12](12-stage2-report.md) |
| 3 | Аккаунты преподавателей, курсовая область видимости, внутренние API identity с `LMS_INTERNAL_TOKEN`, кабинет преподавателя, профиль студента курса и скачивание всех попыток | [13](13-stage3-report.md) |
@@ -31,7 +31,10 @@
недоступны (`404`) всем, кроме админа; админ перечисляет их через
`GET /api/v1/content/legacy-assets`;
- access-токен живёт 15 минут, refresh — 7 дней с ротацией и отзывом; web-сессия
скользящая на 7 дней и сама обновляет access-токен;
скользящая на 7 дней, но не дольше абсолютного потолка 30 дней, и сама
обновляет access-токен; истёкшие refresh-токены очищаются ночью;
- аудит продолжается keyset-курсором `(occurred_at, id)`, а детали всех
лабораторных курса отдаются одним пакетным запросом без N+1;
- преподаватель своего курса может писать: лабораторные/уровни/варианты,
ведомость, привязка групп и авторские материалы курса; чужие курс, лаба,
студент и группа отклоняются на сервисах;
@@ -62,14 +65,15 @@
| Модуль | Тестов | Failures | Errors | Skipped |
|---|---:|---:|---:|---:|
| identity-service | 72 | 0 | 0 | 0 |
| academic-service | 88 | 0 | 0 | 0 |
| progress-service | 33 | 0 | 0 | 0 |
| web | 69 | 0 | 0 | 0 |
| 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** | **267** | **0** | **0** | **0** |
| **Итого на момент этапа 6** | **346** | **0** | **0** | **0** |
После закрытия рисков актуальный LDD-baseline — **319 тестов, 0 failures**
После закрытия рисков актуальный LDD-baseline — **346 тестов, 0 failures**
(см. раздел 7). `ktlintCheck` и `detekt` прошли без замечаний. У gateway теперь есть unit-тесты публичных auth-путей и 401; маршрутизация
gateway дополнительно проверена конфигурацией и security-матрицей ниже.
Тот же набор команд выполняет `.gitlab-ci.yml`; на каждый push/MR добавлен
@@ -86,7 +90,7 @@ smoke-job, полный release-evidence обязателен для тегов.
(`ПИ-11БО`, `ПИ-21БО`), зачисления, семестр, лабораторная, глава/раздел/
статья/актив, оценка, комментарий и попытка сдачи.
3. Поверх master применены миграции ветки:
- identity `V4–V11`;
- identity `V4–V13`;
- academic `V6–V12`;
- progress `V13`.
4. Выполнены postflight-запросы и повторное применение `V13`.
@@ -100,7 +104,10 @@ smoke-job, полный release-evidence обязателен для тегов.
действием `ENROLL`; добавлен `accounts.status`; `V10` создаёт
`identity.audit_events` без внешних ключей и индексы по `occurred_at`/`action`;
`V11` создаёт `identity.refresh_tokens` (SHA-256-хеш, `expires_at`,
`revoked_at`, `replaced_by`, индексы по аккаунту и сроку).
`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`,
@@ -126,7 +133,7 @@ smoke-job, полный release-evidence обязателен для тегов.
### 2.3. Security-проверки
Проверено автоматическими тестами (актуальный прогон — 319 тестов, 0 failures)
Проверено автоматическими тестами (актуальный прогон — 346 тестов, 0 failures)
и разбором конфигурации gateway. Запись преподавателя дополнительно покрыта
negative-тестами «свой/чужой курс».
@@ -141,7 +148,7 @@ negative-тестами «свой/чужой курс».
| Преподаватель своего курса → скрытый курсовой актив | доступ | `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`); `POST /api/v1/auth/refresh` ротирует пару, повторное использование старого токена отзывает все сессии аккаунта |
| 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 |
@@ -149,7 +156,7 @@ negative-тестами «свой/чужой курс».
| Преподаватель своего курса → чтение | курс, группы, профиль, материалы | видит опубликованные и скрытые материалы, всех студентов активной группы курса, неопубликованные оценки и все попытки; `GET /api/v1/courses/{id}/groups` отдаёт привязанные группы с именами одним вызовом (без N+1) |
| Преподаватель своего курса → запись | лабы/уровни/варианты, ведомость, группы, материалы | оценки/комментарии, публикация, уровни/варианты и раздача, защиты (код/reset), привязка групп, авторство материалов; проверки в progress `CourseScopeService` и academic course/content authoring |
| Преподаватель чужого курса | `403` | `403` на чтение и запись: материалы, курс по `course_no`, состав группы, профиль, лабы, оценки, группы |
| Аудит → админ | чтение журнала | `GET /api/v1/audit?page=&limit=&action=` и `/admin/audit` доступны только `ADMIN`; в UI — постраничный переход «Показать ещё» |
| Аудит → админ | чтение журнала | `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` |
@@ -158,10 +165,12 @@ negative-тестами «свой/чужой курс».
| Gateway | внутренние пути не публикуются | маршрутов на `/api/v1/internal/**` нет; gateway требует JWT для всего, кроме `/actuator`, входа/setup/JWKS/refresh/logout и публичного `/api/v1/content/**` |
| Gateway → заголовки личности | клиент не подменяет | `X-Account-Id/Login/Role` удаляются и проставляются только после проверки JWT |
Остаточные ограничения перечислены в разделе 4: offset-пагинация аудита,
N+1 в отдельных лабораторных списках и отсутствие абсолютного лимита
web-сессии. Продуктовые риски legacy-активов, 30-дневного JWT и ведущих
нулей номера группы закрыты или сняты как не относящиеся к требованиям.
Остаточные ограничения перечислены в разделе 4: ручная инвентаризация
legacy-активов на production, сдачи по выбранной лабораторной в ведомости,
непроверенный первый CI-прогон и отсутствие визуальной регрессии. Продуктовые
риски legacy-активов, срока access-токена, пагинации, N+1 деталей лабораторных,
абсолютного лимита сессии и ведущих нулей закрыты или сняты как не относящиеся
к требованиям.
## 3. Чек-лист деплоя
@@ -185,16 +194,19 @@ web-сессии. Продуктовые риски legacy-активов, 30-д
`smoke`/`release-evidence` — shell-runner с Java 21, Docker Compose v2,
Python 3, Node и Chromium Playwright.
- [ ] Заданы сроки сессии: `LMS_ACCESS_TOKEN_TTL=15m`,
`LMS_REFRESH_TOKEN_TTL=7d`, `LMS_SESSION_TIMEOUT=7d` (значения по умолчанию
подходят; менять только осознанно).
`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–V11` (`V10` — `identity.audit_events`, `V11` —
`identity.refresh_tokens`), academic `V6–V12`, progress `V13`.
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 идемпотентно
@@ -213,7 +225,9 @@ web-сессии. Продуктовые риски legacy-активов, 30-д
- `comments_without_author` соответствует старым комментариям;
- группы без полного разбора отсутствуют;
- таблица `identity.audit_events` существует (`V10`);
- таблица `identity.refresh_tokens` существует (`V11`).
- таблица `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` — он относится к другой схеме и
@@ -249,8 +263,8 @@ web-сессии. Продуктовые риски legacy-активов, 30-д
- [ ] **Активы:** картинки курсовой статьи открываются у студента своего курса
и преподавателя; чужой курс — `403`, скрытая картинка у студента — `404`,
публичная `ALL` — без входа; файл без привязки к статье — `404` для всех,
кроме админа. Админ проверяет инвентаризацию через
`GET /api/v1/content/legacy-assets` (или админский UI/curl) и при
кроме админа. Админ открывает `/admin/legacy-assets` (или
`GET /api/v1/content/legacy-assets`, или `scripts/legacy-assets.sh`) и при
необходимости привязывает или удаляет файлы.
- [ ] **Копирование лабораторной:** админ копирует лабораторную в другой курс;
создаётся черновик с уровнями и вариантами; оценки, сдачи, защиты и
@@ -263,13 +277,13 @@ web-сессии. Продуктовые риски legacy-активов, 30-д
- [ ] **Аудит:** `/admin/audit` открывается у админа, показывает события
(например, `PROFILE_VIEW`, `SUBMISSION_DOWNLOAD`, `GRADE_SET`,
`CONTENT_*`, `TEACHER_*`) с фильтром по действию и временем МСК;
«Показать ещё» переходит на следующую страницу (`page`/`limit`);
преподаватель/студент получают `403`.
- [ ] **Сессия:** после входа cookie `LMS_SESSION` живёт 7 дней; при работе
дольше 15 минут страница продолжает работать без повторного входа
(access обновляется автоматически). Выход и смена пароля требуют повторного
входа; вход отчисленного/архивного студента и refresh закрытой группы
отклоняются.
«Показать ещё» переходит по keyset-курсору (`cursor`), «В начало»
возвращает к первой странице; преподаватель/студент получают `403`.
- [ ] **Сессия:** после входа cookie `LMS_SESSION` живёт 7 дней, но не дольше
30 дней с момента входа; при работе дольше 15 минут страница продолжает
работать без повторного входа (access обновляется автоматически). Выход и
смена пароля требуют повторного входа; вход отчисленного/архивного студента
и refresh закрытой группы отклоняются.
- [ ] Проверить логи `web`, `gateway`, `identity`, `academic`, `progress` на
`ERROR`/`WARN` после smoke.
@@ -279,53 +293,65 @@ web-сессии. Продуктовые риски legacy-активов, 30-д
шаблон v2 (поток/курс/номер/степень/год/ФИО/№/база). Ограничение
зафиксировано в [11](11-stage1-report.md). Номер группы — числовой, поэтому
отдельного требования к ведущим нулям нет.
2. **Пагинация аудита — offset.** `GET /api/v1/audit` поддерживает `page` и
`limit` (максимум 500), UI — «Показать ещё». Для очень больших журналов
keyset-пагинация была бы устойчивее к вставкам между страницами.
3. **N+1 в отдельных списках.** Имена групп курса отдаются одним вызовом
(`GET /api/v1/courses/{id}/groups`), но часть лабораторных списков и
ведомости на больших объёмах всё ещё собирается по частям. Полный нагрузочный
профиль 100 групп / 1000 студентов проходит, однако пакетные выборки для
оставшихся экранов — лабораторная деталь, а не блокер передачи.
4. **Нет абсолютного лимита web-сессии.** Сессия скользящая (7 дней) и
отзывается при выходе, смене пароля, отчислении/архиве и закрытии группы,
но при непрерывной активности может жить бессрочно. Если политика требует
принудительного повторного входа раз в N дней, нужен абсолютный `max-age`
поверх sliding-сессии.
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-прогон не подтверждён.** Конфигурация, smoke и
`scripts/ci-preflight.sh` готовы, но наличие GitLab shell-runners с тегами
`lms-shell`/`lms-release` и первый tag-job нужно проверить в GitLab.
6. **Внешняя интеграция «Контингент» исключена из плана.** Отдельного
anti-corruption слоя, синхронизации и внешних идентификаторов не будет без
нового ТЗ и доступа к API.
7. **Mutation testing не 100%.** Остаются эквивалентные мутанты и
Kotlin/Spring-специфичные мутации; PIT harness — JUnit 4.
8. **Нагрузочный smoke — не SLA.** Одна машина, синтетические данные;
125 чтений и p95 < 1 с — ориентир, а не гарантия production.
## 5. Рекомендуемые следующие шаги
Приоритет — сверху вниз:
1. **Инвентаризация legacy-активов.** Несвязанные файлы уже не публичны; админ
получает их список через `GET /api/v1/content/legacy-assets`, после чего
привязывает к статьям или удаляет. Это гигиена хранилища, а не открытый
риск доступа.
2. **Keyset-пагинация и оставшиеся N+1.** Устойчивый курсор для аудита,
пакетные выборки для лабораторных списков и ведомости на больших курсах.
3. **Абсолютный лимит сессии.** Опционально: принудительный повторный вход
раз в N дней поверх 7-дневной sliding-сессии.
4. **Внешняя интеграция «Контингент»/next.uniyar.ac.ru.** Отдельный
anti-corruption слой, идемпотентная синхронизация, журнал и отчёт дрифта;
внешние идентификаторы добавляются миграцией.
1. **Прогнать инвентаризацию legacy-активов на production/staging** через
`/admin/legacy-assets` или `scripts/legacy-assets.sh`, привязать нужные
файлы к статьям, остальные удалить.
2. **Проверить CI на реальном runner.** Убедиться, что есть shell-runners
`lms-shell`/`lms-release` с Java 21, Docker Compose v2, Python 3,
Node/Chromium/Firefox/WebKit; дождаться зелёного push-MR pipeline и
tag-job `release-evidence`.
3. **Дальнейшее расширение E2E** — визуальная регрессия и остальные мобильные
страницы, если UI продолжит расти.
4. **Пакетные сдачи ведомости**, если появятся курсы, где открытие ведомости
станет заметно медленным.
## 6. Итог
Этап 6 закрыт: права преподавателя на запись, аудит, CI, security-матрица,
upgrade-тест миграций, видимость активов и deploy-runbook проверены.
Продуктовые риски закрыты: legacy-активы secure-by-default, access/refresh и
web-сессия с отзывом, пагинация аудита и bulk-выдача групп. Оставшиеся
ограничения (offset-пагинация, отдельные N+1, отсутствие абсолютного лимита
сессии) задокументированы в разделе 4 и не блокируют передачу.
web-сессия с отзывом и абсолютным потолком, keyset-пагинация аудита,
пакетные детали лабораторных и bulk-выдача групп. Оставшиеся ограничения
(ручная инвентаризация на production, сдачи по выбранной лабораторной,
непроверенный первый CI-прогон) задокументированы в разделе 4 и не блокируют
передачу.
## 7. LDD baseline
После этапа 6 и закрытия рисков текущая функциональность проверена через
LDD-контур: реестр `docs/evidence/claims.json` (**46 claims, 25 P0 / 21 P1**),
LDD-контур: реестр `docs/evidence/claims.json` (**51 claims, 25 P0 / 26 P1**),
gate `./gradlew evidenceCheck`, upgrade-скрипт, runtime-smoke, smoke-job,
mutation testing и браузерный Playwright E2E. Результат:
**46/46 claims proven, P0 gaps = 0, 319 тестов, 0 failures**; mutation strength
`CourseAccessService` 75%, `PromotionPlanner` 69%; browser E2E 4/4;
**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 с) зелёные.
Smoke-job выполняется на каждом push/MR, полный `release-evidence` обязателен