diff --git a/.env.prod.example b/.env.prod.example index 567fcfd..56a6fa2 100644 --- a/.env.prod.example +++ b/.env.prod.example @@ -28,3 +28,8 @@ LMS_ACCESS_TOKEN_TTL=15m LMS_REFRESH_TOKEN_TTL=7d # Web-сессия LMS_SESSION: 7 дней sliding, access обновляется автоматически. LMS_SESSION_TIMEOUT=7d +# Жёсткий потолок сессии: даже при ежедневной активности раз в 30 дней нужен повторный вход. +LMS_SESSION_ABSOLUTE_TIMEOUT=30d +# Истёкшие refresh-токены старше этого срока удаляются ночной очисткой. +LMS_REFRESH_TOKEN_RETENTION=30d +LMS_REFRESH_TOKEN_CLEANUP_CRON=0 30 3 * * * diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 80e907f..ac21f56 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -41,6 +41,8 @@ smoke: # Shell-runner: скрипт поднимает реальный стек на localhost и проверяет его. # На runner нужны Java 21, Docker Compose v2, Python 3 и bash. tags: [lms-shell] + before_script: + - ./scripts/ci-preflight.sh cache: key: "gradle-$CI_COMMIT_REF_SLUG" paths: @@ -57,13 +59,15 @@ smoke: - if: '$CI_COMMIT_BRANCH' # Ручной release gate: runtime, mutation и browser evidence. -# Нужен privileged docker:dind; Chromium и Node ставятся в before_script. +# Нужен privileged docker:dind; Chromium/Firefox/WebKit и Node ставятся в before_script. release-evidence: stage: release-evidence - # Shell-runner с Java 21, Docker Compose v2, Node 22, Chromium Playwright. + # Shell-runner с Java 21, Docker Compose v2, Node 22, браузерами Playwright. tags: [lms-release] before_script: - - cd e2e && npm ci && npx playwright install chromium && cd .. + - cd e2e && npm ci && cd .. + - ./scripts/ci-preflight.sh --release + - cd e2e && npx playwright install --with-deps chromium firefox webkit && cd .. cache: key: "gradle-$CI_COMMIT_REF_SLUG" paths: diff --git a/README.md b/README.md index 48f0074..4e91aad 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,16 @@ UML- и архитектурные диаграммы (Mermaid) — в ката - `services/gateway` — Spring Cloud Gateway, маршрутизация и проверка JWT. - `web` — серверный веб-интерфейс (Thymeleaf + htmx), BFF. +Ключевое в текущей версии: + +- роли `ADMIN/TEACHER/STUDENT`, курсы и группы-когорты, повышение и жизненный + цикл студента, профиль студента курса; +- access JWT 15 минут + refresh 7 дней с ротацией/отзывом; web-сессия 7 дней + sliding с абсолютным потолком 30 дней и автоматическим обновлением access; +- аудит с keyset-пагинацией, пакетная выдача лабораторных и групп курса; +- материалы и активы secure-by-default: файл без статьи доступен только + админу (инвентаризация `/admin/legacy-assets`). + ## Требования - JDK 21 (runtime) + Docker (для PostgreSQL 18). Gradle сам скачает toolchain 21 для сборки. @@ -58,8 +68,10 @@ progress `:8083`, web `:8090`. Интерфейс: `http://localhost:8090`. ./scripts/ldd-runtime.sh # runtime-smoke полного стека + scale load ./scripts/ldd-migration-upgrade.sh # upgrade с master-схемы ./scripts/ldd-mutation.sh # PIT: mutation strength критичных P0-компонентов -./scripts/ldd-browser.sh # Playwright E2E (cd e2e && npm install && npx playwright install chromium) +./scripts/ldd-browser.sh # Playwright E2E: Chromium/Firefox/WebKit ./scripts/ldd-release.sh # полный release gate: test/upgrade/mutation/runtime/E2E +./scripts/ci-preflight.sh --release # проверка окружения runner без установки +./scripts/legacy-assets.sh # инвентаризация бесхозных файлов на стенде (admin) ``` - `ktlint` отвечает за формат, `detekt` — за качество (сложность, вложенность, ошибки). @@ -77,7 +89,8 @@ progress `:8083`, web `:8090`. Интерфейс: `http://localhost:8090`. `release-evidence` (`scripts/ldd-release.sh`) обязателен для тегов и запускается вручную для веток. `check` требует privileged `docker:dind` для Testcontainers; `smoke`/`release-evidence` — shell-runner с Java 21, - Docker Compose v2, Python 3 и (для release) Node/Chromium. + Docker Compose v2, Python 3 и (для release) Node + Chromium/Firefox/WebKit. + Baseline: **51 claim, 346 тестов, 18/18 browser E2E**. ## Запуск в Docker (для сервера) diff --git a/deploy/DEPLOY.md b/deploy/DEPLOY.md index 852cea4..78892c9 100644 --- a/deploy/DEPLOY.md +++ b/deploy/DEPLOY.md @@ -60,13 +60,18 @@ Compose передаёт одно и то же значение identity, academ Сроки аутентификации и сессии (можно не задавать — действуют значения ниже): ```dotenv -LMS_ACCESS_TOKEN_TTL=15m # access JWT identity, короткий -LMS_REFRESH_TOKEN_TTL=7d # refresh-токен, ротация и отзыв -LMS_SESSION_TIMEOUT=7d # web-сессия LMS_SESSION, sliding +LMS_ACCESS_TOKEN_TTL=15m # access JWT identity, короткий +LMS_REFRESH_TOKEN_TTL=7d # refresh-токен, ротация и отзыв +LMS_SESSION_TIMEOUT=7d # web-сессия LMS_SESSION, sliding +LMS_SESSION_ABSOLUTE_TIMEOUT=30d # жёсткий потолок сессии +LMS_REFRESH_TOKEN_RETENTION=30d # сколько хранить истёкшие refresh +LMS_REFRESH_TOKEN_CLEANUP_CRON=0 30 3 * * * ``` -`LMS_ACCESS_TOKEN_TTL` и `LMS_REFRESH_TOKEN_TTL` читает identity, -`LMS_SESSION_TIMEOUT` — web. `docker-compose.prod.yml` прокидывает эти +`LMS_ACCESS_TOKEN_TTL`, `LMS_REFRESH_TOKEN_TTL`, +`LMS_SESSION_ABSOLUTE_TIMEOUT`, `LMS_REFRESH_TOKEN_RETENTION` и +`LMS_REFRESH_TOKEN_CLEANUP_CRON` читает identity; `LMS_SESSION_TIMEOUT` и +`LMS_SESSION_ABSOLUTE_TIMEOUT` — web. `docker-compose.prod.yml` прокидывает эти переменные в environment соответствующих сервисов; при переопределении используются значения из `.env.prod`. Access обновляется автоматически за 2 минуты до истечения; прямой API-клиент должен @@ -125,24 +130,30 @@ git pull `deploy.sh up` **обязательно** делает backup перед обновлением. Если backup завершился ошибкой, деплой прерывается. Миграции БД (Flyway) применяются -автоматически при старте сервисов: identity `V4–V11` (`V10` создаёт -`identity.audit_events`, `V11` — `identity.refresh_tokens`), academic `V6–V12`, +автоматически при старте сервисов: identity `V4–V13` (`V10` создаёт +`identity.audit_events`, `V11` — `identity.refresh_tokens`, `V12` — +`absolute_expires_at`, `V13` — keyset-индексы аудита), academic `V6–V12`, progress `V13`. После обновления проверьте, что админ открывает `/admin/audit` и видит события (например, после входа в профиль студента — `PROFILE_VIEW`); преподаватель и -студент на эту страницу не попадают. Журнал листается через `page`/`limit` -(«Показать ещё»). Для инвентаризации старых файлов админ может вызвать -`GET /api/v1/content/legacy-assets`: файлы без привязки к статье больше не -публичны (`404` для обычных пользователей), их нужно привязать или удалить. +студент на эту страницу не попадают. Журнал листается keyset-курсором +(`cursor`/`limit`, «Показать ещё»; offset `page` остаётся для совместимости). +Для инвентаризации старых файлов админ открывает `/admin/legacy-assets`, +вызывает `GET /api/v1/content/legacy-assets` или запускает +`scripts/legacy-assets.sh`: файлы без привязки к статье больше не публичны +(`404` для обычных пользователей), их нужно привязать или удалить. CI (`.gitlab-ci.yml`) на каждом push/MR выполняет `./gradlew --no-daemon test ktlintCheck detekt evidenceCheck` и job `smoke` (`./scripts/ldd-smoke.sh`: полный стек, health, вход админа, один курс). Полный job `release-evidence` (`./scripts/ldd-release.sh`) обязателен для тегов -и запускается вручную для веток. `check` использует privileged `docker:dind` +и запускается вручную для веток. `smoke` и release запускают +`scripts/ci-preflight.sh`, который проверяет Java 21, Docker Compose v2, +Python 3, Node и Playwright. `check` использует privileged `docker:dind` для Testcontainers; `smoke`/`release-evidence` выполняются на shell-runner -с Java 21, Docker Compose v2, Python 3, Node и Chromium Playwright. +с Java 21, Docker Compose v2, Python 3, Node и браузерами +Chromium/Firefox/WebKit. ## 8. Резервное копирование diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 25ddf2d..5a5f9c2 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -47,6 +47,9 @@ services: LMS_DB_URL: jdbc:postgresql://postgres:5432/${LMS_DB_NAME:-lms}?currentSchema=identity LMS_ACCESS_TOKEN_TTL: ${LMS_ACCESS_TOKEN_TTL:-15m} LMS_REFRESH_TOKEN_TTL: ${LMS_REFRESH_TOKEN_TTL:-7d} + LMS_SESSION_ABSOLUTE_TIMEOUT: ${LMS_SESSION_ABSOLUTE_TIMEOUT:-30d} + LMS_REFRESH_TOKEN_RETENTION: ${LMS_REFRESH_TOKEN_RETENTION:-30d} + LMS_REFRESH_TOKEN_CLEANUP_CRON: ${LMS_REFRESH_TOKEN_CLEANUP_CRON:-0 30 3 * * *} depends_on: postgres: { condition: service_healthy } networks: [lms] @@ -108,6 +111,7 @@ services: LMS_GATEWAY_URL: http://gateway:8080 SERVER_PORT: 8090 LMS_SESSION_TIMEOUT: ${LMS_SESSION_TIMEOUT:-7d} + LMS_SESSION_ABSOLUTE_TIMEOUT: ${LMS_SESSION_ABSOLUTE_TIMEOUT:-30d} ports: - "127.0.0.1:${LMS_WEB_PORT:-8090}:8090" depends_on: diff --git a/docs/02-deployment.md b/docs/02-deployment.md index 5a2d79a..b1bd3ad 100644 --- a/docs/02-deployment.md +++ b/docs/02-deployment.md @@ -53,8 +53,10 @@ flowchart TB в контейнерах это `http://identity:8081/api/v1/auth/jwks.json` (иначе 401). Сроки сессии задаются переменными (значения по умолчанию подходят): -`LMS_ACCESS_TOKEN_TTL=15m` и `LMS_REFRESH_TOKEN_TTL=7d` для identity, -`LMS_SESSION_TIMEOUT=7d` для web. +`LMS_ACCESS_TOKEN_TTL=15m`, `LMS_REFRESH_TOKEN_TTL=7d`, +`LMS_SESSION_ABSOLUTE_TIMEOUT=30d`, `LMS_REFRESH_TOKEN_RETENTION=30d`, +`LMS_REFRESH_TOKEN_CLEANUP_CRON` для identity; `LMS_SESSION_TIMEOUT=7d` и +`LMS_SESSION_ABSOLUTE_TIMEOUT=30d` для web. ## 3. Сборка @@ -105,7 +107,7 @@ sequenceDiagram DC->>DC: docker build (5 стадий) DC->>PG: старт postgres (volume lms_pgdata) DC->>DC: старт identity → academic → progress → gateway → web - Note over DC: Flyway применяет миграции автоматически
при старте сервисов (identity V1..V11, academic V1..V12, progress V1..V9 и V11..V13; у web миграций нет) + Note over DC: Flyway применяет миграции автоматически
при старте сервисов (identity V1..V13, academic V1..V12, progress V1..V9 и V11..V13; у web миграций нет) ``` - Локально (`docker-compose.yml`) поднимается только `postgres`; прод-стек — `docker-compose.prod.yml`. diff --git a/docs/07-groups-courses.md b/docs/07-groups-courses.md index 584b8e0..bc04450 100644 --- a/docs/07-groups-courses.md +++ b/docs/07-groups-courses.md @@ -227,9 +227,10 @@ CREATE INDEX audit_events_action_idx ON identity.audit_events (action); ``` Внешних ключей нет: событие должно переживать удаление субъекта. Запись идёт из сервисов через `POST /api/v1/internal/audit` (`X-Internal-Token`), чтение — -`GET /api/v1/audit?page=&limit=&action=` и `/admin/audit` только для админа. -Страница аудита поддерживает `page` (с нуля) и `limit` (максимум 500), в UI — -переход «Показать ещё». +`GET /api/v1/audit?limit=&action=&cursor=` и `/admin/audit` только для админа. +Страница аудита продолжается keyset-курсором `(occurred_at, id)`; offset `page` +остаётся для совместимости, `limit` — максимум 500. В UI — «Показать ещё» и +«В начало»; битый курсор даёт `400`. **V11 — refresh-токены (добавлена при закрытии рисков аутентификации)** ```sql @@ -422,7 +423,8 @@ V13 идемпотентна: если старая feature-миграция V10 и защиты. Свой профиль студент открывает без записи в аудит, чтобы не засорять журнал и не нагружать identity на горячем пути. Админ смотрит журнал на `/admin/audit` с фильтром по действию; время отображается в МСК. -Журнал читается страницами (`page`/`limit`, «Показать ещё»), а не только +Журнал читается keyset-курсором (`cursor`/`limit`, «Показать ещё»; offset +`page` — только совместимость), а не только последними N событиями. `membership_history` и `promotion_runs/items` остаются доменной историей и дополняют общий журнал. @@ -433,19 +435,20 @@ V13 идемпотентна: если старая feature-миграция V10 сервисами на каждом запросе). - **Refresh-токен** — 7 дней (`LMS_REFRESH_TOKEN_TTL`), хранится хешем в `identity.refresh_tokens` (`V11`), ротируется при каждом обновлении. - Повторное использование старого токена отзывает все активные сессии - аккаунта; выход, смена пароля, отчисление/выпуск/архив и закрытая группа - обновление запрещают. + Абсолютный потолок сессии — 30 дней (`LMS_SESSION_ABSOLUTE_TIMEOUT`, `V12`): + ротация не продлевает `absolute_expires_at`. Повторное использование старого + токена отзывает все активные сессии аккаунта; выход, смена пароля, + отчисление/выпуск/архив и закрытая группа обновление запрещают. Истёкшие + токены старше retention удаляет ночная задача. - **Прямой API-клиент** обязан обновлять пару через `POST /api/v1/auth/refresh`; `POST /api/v1/auth/logout` отзывает refresh. - **Web-сессия** — 7 дней sliding (`LMS_SESSION_TIMEOUT`), cookie `LMS_SESSION` - (`HttpOnly`, `SameSite=Lax`, `Max-Age` обновляется на каждом ответе). - Access-токен автоматически обновляется за 2 минуты до истечения, поэтому - активная вкладка не требует повторного входа каждые 15 минут; при выходе - refresh отзывается. -- **Ограничение:** абсолютного лимита жизни сессии нет — при непрерывной - активности она продлевается. Если политика потребует принудительный - повторный вход раз в N дней, добавляется отдельный абсолютный `max-age`. + (`HttpOnly`, `SameSite=Lax`, `Max-Age` обновляется на каждом ответе), но не + дольше 30 дней с момента входа (`LMS_SESSION_ABSOLUTE_TIMEOUT`). После + абсолютного срока cookie получает `Max-Age=0`, а пользователь — повторный + вход. Access-токен автоматически обновляется за 2 минуты до истечения, + поэтому активная вкладка не требует повторного входа каждые 15 минут; при + выходе refresh отзывается. --- @@ -457,8 +460,9 @@ V13 идемпотентна: если старая feature-миграция V10 - **Активы (реализовано):** видимость актива наследуется от статьи → раздела → главы, маршрут `/api/v1/content/assets/{name}` проверяет доступ. Файл без связи со статьёй не публичен: аноним/студент/преподаватель получают `404`, - админ может скачать файл и получить список таких файлов через - `GET /api/v1/content/legacy-assets` для привязки или удаления. Старые + админ может скачать файл, открыть страницу `/admin/legacy-assets`, вызвать + `GET /api/v1/content/legacy-assets` или `scripts/legacy-assets.sh` для + привязки или удаления. Старые внешние ссылки на несвязанные файлы у обычных пользователей перестают работать — это осознанное secure-by-default поведение. - Поисковая индексация: для `ALL` — по умолчанию разрешена; для остального — закрыть (robots/noindex). @@ -527,13 +531,16 @@ V13 идемпотентна: если старая feature-миграция V10 --- -## 12. Интеграция «Контингент» / next.uniyar.ac.ru (задел) +## 12. Интеграция «Контингент» / next.uniyar.ac.ru — исключена из плана -- Позже — **отдельный сервис-адаптер** (anti-corruption layer); внешняя система — источник истины по - курсу/группе/статусу; LMS — зеркало; синхронизация односторонняя + отчёт дрифта. -- Сейчас закладываем: `student_no` уникальный и стабильный (ключ склейки); внешние id добавим миграцией. -- Обязательно: идемпотентность, журнал синхронизаций, отчёт расхождений, обработка академа/переводов/отчислений, - устойчивость к недоступности внешней системы, ПДн. +Решением от 2026-10-28 интеграция **не входит в объём работ**: нет API-спецификации +и доступа к внешней системе, а без них можно сделать только непроверяемую заглушку. +`student_no` остаётся уникальным и стабильным, поэтому при появлении нового ТЗ +адаптер можно добавить без переделки модели. + +Если интеграция понадобится позже: отдельный anti-corruption layer, идемпотентная +синхронизация, журнал, отчёт дрифта, обработка академа/переводов/отчислений, +устойчивость к недоступности внешней системы и ПДн. --- @@ -573,6 +580,6 @@ V13 идемпотентна: если старая feature-миграция V10 4. Запись/снятие групп на курс. 5. Повышение курса: dry-run, журнал, идемпотентность, откат, выпуск. 6. Академический отпуск/возврат, отчисление, выпуск, архив; усиленное удаление. -7. (Позже) сервис-адаптер «Контингент»/`next.uniyar.ac.ru`. +7. ~~Сервис-адаптер «Контингент»/`next.uniyar.ac.ru`~~ — исключён из плана. Открытых вопросов нет: все решения зафиксированы в §1. diff --git a/docs/08-groups-courses-plan.md b/docs/08-groups-courses-plan.md index a0ab141..172e32d 100644 --- a/docs/08-groups-courses-plan.md +++ b/docs/08-groups-courses-plan.md @@ -211,11 +211,11 @@ Номера уточняются при реализации, чтобы не конфликтовать с максимумами master (identity V3, **academic V5**, progress V12). Максимумы этой ветки: -identity V11, **academic V12**, progress V13. +identity V13, **academic V12**, progress V13. | Сервис | Миграции | Содержание | |---|---|---| -| identity | V4–V11 | роли, поля групп, статусы студентов, `membership_history`, `promotion_runs/items`, `accounts.status`; `V10` — `audit_events` без внешних ключей и индексы по `occurred_at`/`action`; `V11` — `refresh_tokens` (хеш, срок, отзыв, цепочка замен) | +| identity | V4–V13 | роли, поля групп, статусы студентов, `membership_history`, `promotion_runs/items`, `accounts.status`; `V10` — `audit_events`; `V11` — `refresh_tokens` (хеш, срок, отзыв, цепочка замен); `V12` — `absolute_expires_at`; `V13` — keyset-индексы аудита | | academic | V6–V12 + post-bootstrap step | курсы, преподаватели, группы курса, `labs.course_id` (nullable), материалы курса/аудитории, частичные индексы, активы; `V10` — уникальный индекс `lower(title)` по курсам; `V11` — backfill `audience='ALL'` для глобальных глав; `V12` — backfill связей `article_assets` из markdown-ссылок; `SET NOT NULL` — post-bootstrap шаг вне Flyway | | progress | V13 | `grade_comments.author_account_id`, `lab_grades.updated_by_account_id`, индекс `lab_grades(lab_id)`; аудит реализован в identity `V10` | @@ -297,13 +297,15 @@ identity V11, **academic V12**, progress V13. - **Legacy-активы.** Закрыто: связанные активы наследуют видимость статьи (миграция `V12`), а файлы без markdown-ссылки по умолчанию отдают `404` анониму/студенту/преподавателю. Админ перечисляет их через - `GET /api/v1/content/legacy-assets` для привязки или удаления. + `GET /api/v1/content/legacy-assets`, страницу `/admin/legacy-assets` или + `scripts/legacy-assets.sh` для привязки или удаления. - **JWT и сессии.** Закрыто: access-токен 15 минут, refresh 7 дней с ротацией - и отзывом, web-сессия 7 дней sliding. Прямые API-клиенты используют - `POST /api/v1/auth/refresh`; logout и смена пароля отзывают refresh. -- **Оставшаяся производительность.** Аудит постраничный и группы курса - отдаются одним вызовом, но N+1 в отдельных лабораторных списках и - offset-пагинация аудита остаются лабораторными деталями. + и отзывом, web-сессия 7 дней sliding с абсолютным потолком 30 дней. + Прямые API-клиенты используют `POST /api/v1/auth/refresh`; logout и смена + пароля отзывают refresh; истёкшие токены чистит ночная задача. +- **Производительность.** Аудит продолжается keyset-курсором, детали всех + лабораторных курса и группы курса отдаются пакетно. Отдельный запрос сдач + по выбранной лабораторной в ведомости остаётся, но это не N+1 по студентам. - **Комментарии без автора.** Старые записи останутся с «автор не указан». - **Архивные студенты.** Открывать ли профиль после отвязки группы — уточнить на этапе 3; по умолчанию история курса доступна, если студент был в курсе. @@ -334,25 +336,31 @@ identity V11, **academic V12**, progress V13. - **Этап 6 — выполнен.** Права преподавателя на запись (лабораторные, ведомость, группы, материалы), центральный аудит `identity.audit_events` с `/admin/audit`, CI merge gate и полный hardening. Проверены прогон - тестов/линтеров (**319**, 0 failures), upgrade миграций с master-схемы - (identity `V4–V11`, academic `V6–V12`, progress `V13`), security-матрица, - gateway, видимость активов и deploy-runbook. LDD baseline: **46 claims - (25 P0 / 21 P1), 46/46 proven**, browser E2E 4/4. Отчёт: + тестов/линтеров (**346**, 0 failures), upgrade миграций с master-схемы + (identity `V4–V13`, academic `V6–V12`, progress `V13`), security-матрица, + gateway, видимость активов и deploy-runbook. LDD baseline: **51 claims + (25 P0 / 26 P1), 51/51 proven**, browser E2E 18/18 (3 браузера). Отчёт: [16-stage6-report.md](16-stage6-report.md). ### 9.1. Известные пробелы после этапа 6 - **Импорт.** Старый формат не поддерживается, принимается только шаблон v2. Номер группы — числовой; отдельного требования к ведущим нулям нет. -- **Пагинация и N+1.** `GET /api/v1/audit` поддерживает `page` + `limit`, - в UI есть «Показать ещё»; `GET /api/v1/courses/{id}/groups` отдаёт - привязанные группы с именами одним вызовом. Остаются offset-пагинация - аудита и N+1 в отдельных лабораторных списках/ведомости на больших курсах. -- **Абсолютный лимит сессии.** Web-сессия скользящая (7 дней) и отзывается - при выходе/смене пароля/закрытии доступа, но при непрерывной активности - может жить бессрочно; при необходимости нужен абсолютный `max-age`. -- **Внешняя интеграция.** «Контингент»/`next.uniyar.ac.ru` остаётся заделом: - отдельный anti-corruption слой и внешние идентификаторы — позже. +- **Пагинация и N+1.** Аудит продолжается keyset-курсором `(occurred_at, id)` + с offset-fallback для старых ссылок; `GET /api/v1/labs/details` отдаёт все + лабораторные курса с уровнями и вариантами одним запросом; группы курса — + одним вызовом. Ведомость по-прежнему запрашивает сдачи по выбранной + лабораторной — это не N+1 по студентам. +- **Legacy-активы.** Файлы без статьи уже не публичны; админская страница + `/admin/legacy-assets`, API и `scripts/legacy-assets.sh` готовы. Остаётся + прогнать инвентаризацию на production/staging и привязать или удалить файлы. +- **Сессии.** Web-сессия скользящая 7 дней, абсолютный потолок 30 дней + (`LMS_SESSION_ABSOLUTE_TIMEOUT`); истёкшие refresh-токены чистит ночная + задача (`LMS_REFRESH_TOKEN_RETENTION`). +- **CI.** Конфигурация и `ci-preflight.sh` готовы; первый реальный pipeline + на GitLab shell-runners ещё нужно подтвердить. +- **Внешняя интеграция «Контингент»/`next.uniyar.ac.ru` исключена из плана** + до появления нового ТЗ и доступа к API. ## 10. Definition of Done @@ -363,12 +371,13 @@ identity V11, **academic V12**, progress V13. - Профиль показывает статистику, комментарии и все попытки; файлы скачиваются. - Повышение, академотпуск, выпуск и архив работают по ТЗ. - Аудит чувствительных действий пишется в `identity.audit_events` и доступен - админу на `/admin/audit` с пагинацией. + админу на `/admin/audit` с keyset-пагинацией. - Активы наследуют видимость материала; файлы без привязки не публичны и - доступны только админу через инвентаризацию `legacy-assets`. + доступны только админу через страницу `/admin/legacy-assets`. - Access/refresh-токены и web-сессия работают по срокам 15 минут / 7 дней / - 7 дней; logout, смена пароля и закрытие доступа отзывают refresh. -- Security-матрица и миграционный upgrade-тест зелёные (identity до `V11`). + 7 дней sliding с абсолютным потолком 30 дней; logout, смена пароля и + закрытие доступа отзывают refresh. +- Security-матрица и миграционный upgrade-тест зелёные (identity до `V13`). - CI на каждом push/MR прогоняет `test ktlintCheck detekt evidenceCheck` и smoke полного стека; полный release-evidence обязателен для тегов. - Backup перед деплоем обязателен; restore проверен на staging. diff --git a/docs/10-stage0-report.md b/docs/10-stage0-report.md index dd913c7..5d2cd72 100644 --- a/docs/10-stage0-report.md +++ b/docs/10-stage0-report.md @@ -9,10 +9,11 @@ - ADR с ключевыми решениями: [adr/0001-groups-courses-foundation.md](adr/0001-groups-courses-foundation.md). - Контракт API профиля студента: [09-teacher-student-profile.md](09-teacher-student-profile.md). - Миграции: - - identity `V4–V11`: роли, группы-когорты, статусы студентов, повышение, + - identity `V4–V13`: роли, группы-когорты, статусы студентов, повышение, история членства, статусы аккаунтов; `V10` (`audit_events`) добавлена позже, на этапе 6; `V11` (`refresh_tokens`) — при закрытии рисков - аутентификации, тоже после этого отчёта; + аутентификации, тоже после этого отчёта; `V12` (`absolute_expires_at`) и + `V13` (keyset-индексы аудита) — при финальной доработке; - academic `V6–V12`: курсы, преподаватели, группы курса, `labs.course_id`, материалы курса/аудитории, индексы активов; `V10` (уникальный индекс `lower(title)` по курсам) добавлена на этапе 2, после этого отчёта; diff --git a/docs/15-stage5-course-materials.md b/docs/15-stage5-course-materials.md index 2b5b462..7518c4b 100644 --- a/docs/15-stage5-course-materials.md +++ b/docs/15-stage5-course-materials.md @@ -54,12 +54,12 @@ `V12` закрывает файлы, на которые есть ссылки в markdown. Если файл был загружен, но ни одна статья на него не ссылается, он больше **не публичен**: аноним, студент и преподаватель получают `404`, чтобы не раскрывать -существование файла. Админ скачивает такой файл и получает его имя в -инвентаризации `GET /api/v1/content/legacy-assets`, после чего привязывает -файл к статье или удаляет. Старые внешние ссылки на несвязанные файлы у -обычных пользователей перестают работать — это осознанное поведение -secure-by-default, а не регрессия. Эндпоинт инвентаризации защищён -`ROLE_ADMIN`. +существование файла. Админ скачивает такой файл и получает его имя на +странице `/admin/legacy-assets`, через `GET /api/v1/content/legacy-assets` +или `scripts/legacy-assets.sh`, после чего привязывает файл к статье или +удаляет. Старые внешние ссылки на несвязанные файлы у обычных пользователей +перестают работать — это осознанное поведение secure-by-default, а не +регрессия. Эндпоинт и страница инвентаризации защищены `ROLE_ADMIN`. ## Авторство материалов преподавателем (добавлено на этапе 6) diff --git a/docs/16-stage6-report.md b/docs/16-stage6-report.md index a8e217b..f89ec55 100644 --- a/docs/16-stage6-report.md +++ b/docs/16-stage6-report.md @@ -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` обязателен diff --git a/docs/17-handoff.md b/docs/17-handoff.md index 6dde71f..1d82785 100644 --- a/docs/17-handoff.md +++ b/docs/17-handoff.md @@ -1,8 +1,8 @@ # 17. Handoff: состояние проекта после закрытия продуктовых рисков **Дата:** 2026-10-28. -**Ветка:** `feature/groups-courses` (54 коммита от master до этого документа). -**HEAD на момент написания:** `1ca507b` (`docs: закрытие продуктовых рисков и новый LDD baseline`). +**Ветка:** `feature/groups-courses` (последняя история — `git log --oneline`). +**Последний коммит перед этой итерацией:** `3aae2b8` (`docs: handoff после закрытия продуктовых рисков`). **Пуш:** не выполнялся. **Рабочее дерево:** чистое. `exports/` и `texput.log` игнорируются и в коммиты не входят. @@ -57,8 +57,8 @@ **Legacy-активы** - Файл без записи в `article_assets` больше не публичен: аноним/студент/преподаватель → `404`; админ → доступ. -- Инвентаризация: `GET /api/v1/content/legacy-assets` (ADMIN) возвращает - список файлов без привязки. +- Инвентаризация: `GET /api/v1/content/legacy-assets` (ADMIN), страница + `/admin/legacy-assets` и `scripts/legacy-assets.sh` для работы со стендом. - Старые прямые ссылки на бесхозные файлы у обычных пользователей ломаются осознанно. @@ -66,17 +66,21 @@ - Access JWT: **15 минут** (`LMS_ACCESS_TOKEN_TTL`). - Refresh-токен: **7 дней** (`LMS_REFRESH_TOKEN_TTL`), хранится как SHA-256 hash, ротируется при обновлении. +- Абсолютный потолок сессии: **30 дней** (`LMS_SESSION_ABSOLUTE_TIMEOUT`); + ротация refresh не продлевает `absolute_expires_at` (миграция `V12`). - Web-сессия: **7 дней sliding** (`LMS_SESSION_TIMEOUT`), cookie `LMS_SESSION`; - каждый ответ перевыпускает cookie с новым `Max-Age`; access обновляется - автоматически за 2 минуты до истечения. + каждый ответ перевыпускает cookie с новым `Max-Age`, но не дольше остатка + абсолютного срока; access обновляется автоматически за 2 минуты до истечения. - Logout и смена пароля отзывают refresh; отчисленный/архивный студент, архивный аккаунт и закрытая группа не могут обновить сессию. - Повторное использование старого refresh отзывает все активные сессии аккаунта. +- Истёкшие refresh-токены старше 30 дней удаляет ночная задача + (`LMS_REFRESH_TOKEN_RETENTION`, `LMS_REFRESH_TOKEN_CLEANUP_CRON`). - Gateway публикует без access-JWT: `/auth/login`, `/auth/setup`, `/auth/refresh`, `/auth/logout`, `/auth/health`, `/auth/jwks.json`, `/content/**`, `/actuator`. -- Миграция `identity V11__refresh_tokens.sql`. +- Миграции `identity V11__refresh_tokens.sql` и `V12__refresh_absolute.sql`. ### 1.5. Аудит @@ -84,8 +88,10 @@ админский `GET /api/v1/audit`. - Пишутся доступы/изменения курсов, групп, материалов, лабораторных, оценки, публикации, защиты, профиль (staff), скачивания, lifecycle, promotion. -- `/admin/audit`: фильтр по действию, страницы `page`/`limit` и «Показать ещё». -- Пагинация пока offset, не keyset. +- `/admin/audit`: фильтр по действию, keyset-курсор `(occurred_at, id)`, + «Показать ещё» и «В начало»; offset `page` остался для совместимости. +- `V13` добавляет индексы `(occurred_at DESC, id DESC)` и + `(action, occurred_at DESC, id DESC)`. ### 1.6. Материалы и активы @@ -106,26 +112,30 @@ ### 1.8. LDD и проверки - Реестр: `docs/evidence/claims.json`. -- **46 claims**: 25 P0 + 21 P1; **46/46 proven, P0 gaps = 0**. -- **319 автотестов**, 0 failures/errors/skipped. +- **51 claim**: 25 P0 + 26 P1; **51/51 proven, P0 gaps = 0**. +- **346 автотестов**, 0 failures/errors/skipped. - Mutation testing: - `CourseAccessService` — 75% test strength; - `PromotionPlanner` — 69%. -- Browser E2E Playwright: 4/4 (admin, teacher, student/anonymous, сдача + mobile). +- Browser E2E Playwright: 6 сценариев × Chromium/Firefox/WebKit = **18/18** + (admin, teacher, student/anonymous, сдача + mobile, cursor-аудит, + инвентаризация активов). - Runtime/load: 100 групп / 1000 студентов, 125 параллельных чтений, 0 ошибок, p95 < 1 c. - CI: - `check` на push/MR: тесты, ktlint, detekt, `evidenceCheck`; - - `smoke` на push/MR: полный стек, health, вход админа, один курс; + - `smoke` на push/MR: preflight + полный стек, health, вход админа, один курс; - `release-evidence` обязателен для тегов, вручную для веток: тесты, миграции, mutation, runtime/load, browser E2E, финальный evidence. -- Полный `./scripts/ldd-release.sh` локально прошёл. + - `scripts/ci-preflight.sh` проверяет Java 21, Docker Compose v2, Python 3, + Node и Playwright; первый реальный GitLab pipeline ещё не подтверждён. +- Полный `./scripts/ldd-release.sh` локально прошёл; browser E2E — 18/18. ### 1.9. Миграции | Сервис | Диапазон | Что важно | |---|---|---| -| identity | `V4–V11` | роли, группы, статусы, promotion, история, аудит (`V10`), refresh (`V11`) | +| identity | `V4–V13` | роли, группы, статусы, promotion, история, аудит (`V10`), refresh (`V11`), absolute (`V12`), keyset-индексы (`V13`) | | academic | `V6–V12` | курсы, `labs.course_id`, главы/аудитории, индексы, `article_assets`, backfill | | progress | `V13` | автор комментария/оценки; V13 идемпотентна | @@ -145,7 +155,8 @@ сроки сессии можно переопределить. - `docker-compose.prod.yml` прокидывает: `LMS_ACCESS_TOKEN_TTL`, `LMS_REFRESH_TOKEN_TTL`, `LMS_SESSION_TIMEOUT`, - `LMS_INTERNAL_TOKEN`. + `LMS_SESSION_ABSOLUTE_TIMEOUT`, `LMS_REFRESH_TOKEN_RETENTION`, + `LMS_REFRESH_TOKEN_CLEANUP_CRON`, `LMS_INTERNAL_TOKEN`. - Перед деплоем: backup, preflight, затем deploy, bootstrap курса C (если курсов нет), postflight, smoke. - Gateway не маршрутизирует `/api/v1/internal/**`. @@ -158,7 +169,7 @@ # 1. Полный локальный gate ./gradlew test ktlintCheck detekt evidenceCheck -# 2. Полный LDD release (долго, нужны Docker/Java/Node/Chromium) +# 2. Полный LDD release (долго, нужны Docker/Java/Node + Chromium/Firefox/WebKit) ./scripts/ldd-release.sh # 3. Быстрый smoke (то же, что CI smoke) @@ -175,10 +186,11 @@ cat build/reports/evidence/index.md ``` Последний подтверждённый результат: -- `319` тестов, 0 failures; -- `46/46` claims; +- `346` тестов, 0 failures; +- `51/51` claims; - `ldd-release.sh` — PASS; - smoke — PASS; +- browser E2E — 18/18 (Chromium/Firefox/WebKit); - `git diff --check` — чисто. Артефакты: `build/evidence/{runtime,migration,mutation,browser,smoke}.json`. @@ -187,51 +199,33 @@ cat build/reports/evidence/index.md ## 3. Что осталось внести / закрыть -Это уже не текущие P0-риски, а следующий backlog. Порядок recommended. +Это уже не P0-риски, а эксплуатационный backlog. Порядок recommended. 1. **Инвентаризация legacy-активов на реальном хранилище.** - Endpoint `/api/v1/content/legacy-assets` готов. Нужно на production/staging - получить список бесхозных файлов, привязать их к статьям или удалить; - после этого можно решить, удалять ли админский fallback. - Старые внешние ссылки на такие файлы уже не работают у обычных пользователей. + Страница `/admin/legacy-assets`, API и `scripts/legacy-assets.sh` готовы. + Нужно на production/staging получить список бесхозных файлов, привязать их + к статьям или удалить. Старые внешние ссылки на такие файлы уже не работают + у обычных пользователей. -2. **Keyset-пагинация аудита.** - Сейчас `page`/`limit` offset. Для очень больших журналов возможны сдвиги - при вставках между страницами. Нужен курсор `(occurred_at, id)`. +2. **Первый реальный CI-прогон.** + Конфиг, smoke и `scripts/ci-preflight.sh` готовы, но нужно убедиться, что в + GitLab есть shell-runners с тегами `lms-shell` (smoke) и `lms-release` + (полный release), Java 21, Docker Compose v2, Python 3, Node и браузерами + Chromium/Firefox/WebKit. `check` использует privileged `docker:dind`. + Дождаться зелёного push-MR pipeline и первого tag-job `release-evidence`. -3. **Оставшиеся N+1.** - - `AcademicClient.getCourseLabs` подтягивает детали лабораторных по одной; - - часть данных ведомости/профиля на больших курсах собирается по частям. - Имена групп курса уже bulk; аудит paginated. - Нужны пакетные эндпоинты или join-выборки, затем повторный load-тест. +3. **Визуальная регрессия и мобильный набор E2E.** + Сейчас 6 сценариев × 3 браузера, без screenshot-diff. Добавлять по мере + роста UI. -4. **Абсолютный лимит web-сессии.** - Сессия sliding 7 дней и отзывается, но при непрерывной активности может - жить бессрочно. Если политика требует повторный вход раз в N дней — - добавить абсолютный `max-age`/`absolute timeout`. +4. **Пакетные сдачи в ведомости.** + Детали лабораторных курса уже пакетные, но ведомость запрашивает сдачи по + выбранной лабораторной отдельно. Оптимизировать, если появятся медленные + курсы. -5. **Очистка refresh_tokens.** - Истёкшие записи сейчас не удаляются. Добавить scheduled cleanup - (например, раз в сутки) и метрику/аудит. - -6. **CI на реальном runner.** - Конфиг готов, но нужно убедиться, что в GitLab есть shell-runners с тегами - `lms-shell` (smoke) и `lms-release` (полный release), Java 21, - Docker Compose v2, Python 3, Node/Chromium. `check` использует - privileged `docker:dind` для Testcontainers. - Проверить первый pipeline на push и первый tag-job. - -7. **Browser E2E: расширение.** - Сейчас 4 сценария, один Chromium. Нет Safari/Firefox, визуальной регрессии - и полного мобильного набора. Добавлять по мере роста UI. - -8. **Внешняя интеграция «Контингент»/next.uniyar.ac.ru.** - Отдельный anti-corruption слой, идемпотентная синхронизация, журнал, - отчёт дрифта, внешние идентификаторы. В текущий объём не входила. - -9. **Release-evidence job на теге.** - В `.gitlab-ci.yml` он обязателен для тегов и ручной для веток, но реальный - прогон в CI ещё не подтверждён — см. п. 6. +«Контингент»/`next.uniyar.ac.ru` **исключён из плана** решением от 2026-10-28: +нет API-спецификации и доступа к внешней системе. При появлении нового ТЗ +вернуться к отдельному anti-corruption слою. --- @@ -240,15 +234,25 @@ cat build/reports/evidence/index.md - Не возвращать публичный доступ к legacy-активам. - Не добавлять `number_label`/строковый номер группы: по требованиям номер числовой, ведущего нуля быть не может. -- Не делать access JWT снова 30-дневным. 30 дней — только refresh/web session. +- Не делать access JWT снова 30-дневным. 30 дней — только абсолютный потолок + сессии. - Gateway обязан пропускать `/api/v1/auth/refresh` и `/api/v1/auth/logout` без access-JWT; иначе автообновление и logout ломаются. -- `LMS_ACCESS_TOKEN_TTL`, `LMS_REFRESH_TOKEN_TTL`, `LMS_SESSION_TIMEOUT` - должны попадать в environment соответствующих контейнеров; сейчас - `docker-compose.prod.yml` это делает. -- Session cookie `LMS_SESSION` перевыпускается фильтром на каждом ответе; - не убирать его, иначе 7-дневная sliding-сессия станет абсолютной. +- `LMS_ACCESS_TOKEN_TTL`, `LMS_REFRESH_TOKEN_TTL`, `LMS_SESSION_TIMEOUT`, + `LMS_SESSION_ABSOLUTE_TIMEOUT`, `LMS_REFRESH_TOKEN_RETENTION` и + `LMS_REFRESH_TOKEN_CLEANUP_CRON` должны попадать в environment + соответствующих контейнеров; сейчас `docker-compose.prod.yml` это делает. +- Session cookie `LMS_SESSION` перевыпускается фильтром на каждом ответе, но + `Max-Age` ограничен остатком абсолютного срока; не убирать ни фильтр, ни + проверку `AUTHENTICATED_AT`, иначе 30-дневный потолок перестанет работать. +- Keyset-аудит использует `(occurred_at, id)`; не менять сортировку на одну + `id` или `occurred_at` без пересчёта курсора. Offset `page` — только + совместимость. - `article_assets` без связи → 404 для всех, кроме админа. Это намеренно. +- `/api/v1/labs/details` — пакетный контракт progress; не заменять его обратно + на цикл `/labs/{id}` без load-теста. +- WebKit на Fedora требует Docker-образа Playwright; `ldd-browser.sh` сам + переключается, не ломать этот fallback. - Не коммитить `exports/`, `texput.log`, `data/`, `backups/`, `.env.prod`. - Не включать Flyway `out-of-order`; progress V10 не существует. - `labs.course_id NOT NULL` применяется bootstrap-сервисом в runtime. @@ -264,12 +268,12 @@ cat build/reports/evidence/index.md `feature/groups-courses`. 2. Прочитать этот файл, затем `docs/16-stage6-report.md` §7 и `docs/evidence/baseline-2026-10-27.md`. -3. `./gradlew test ktlintCheck detekt evidenceCheck` — должно быть 319 тестов - и 46/46 claims. +3. `./gradlew test ktlintCheck detekt evidenceCheck` — должно быть 346 тестов + и 51/51 claims. 4. Для release-проверки: `./scripts/ldd-release.sh`. -5. Для быстрой проверки: `./scripts/ldd-smoke.sh`. -6. Продолжать backlog из §3, начиная с инвентаризации legacy-активов - и keyset-пагинации. +5. Для быстрой проверки: `./scripts/ldd-smoke.sh` и `./scripts/ci-preflight.sh`. +6. Продолжать backlog из §3, начиная с инвентаризации legacy-активов и + первого реального CI-прогона. ## 6. Карта ключевых файлов @@ -278,14 +282,19 @@ cat build/reports/evidence/index.md - `docs/16-stage6-report.md` — итоговый отчёт, deploy и ограничения. - `docs/evidence/claims.json` — LDD-реестр. - `docs/evidence/baseline-2026-10-27.md` — фактический baseline. -- `services/identity-service/.../auth/refresh/` — refresh-токены. -- `services/identity-service/.../db/migration/V11__refresh_tokens.sql`. -- `services/identity-service/.../audit/` — аудит. +- `services/identity-service/.../auth/refresh/` — refresh-токены, absolute, + cleanup. +- `services/identity-service/.../db/migration/V11__refresh_tokens.sql`, + `V12__refresh_absolute.sql`, `V13__audit_keyset_indexes.sql`. +- `services/identity-service/.../audit/` — аудит и keyset-курсор. - `services/academic-service/.../content/` — материалы/активы. - `services/academic-service/.../course/CourseGroupService.kt` — bulk-группы. +- `services/academic-service/.../lab/LabService.kt` — `details(courseId)`. +- `services/progress-service/.../AcademicClient.kt` — пакетный вызов лаб. - `services/progress-service/.../CourseScopeService.kt` — course-scope. -- `web/.../session/SessionTokenService.kt`, `SessionCookieRefreshFilter.kt`. -- `web/.../admin/AdminAuditController.kt`. +- `web/.../session/SessionTokenService.kt`, `SessionLifetime.kt`, + `SessionCookieRefreshFilter.kt`. +- `web/.../admin/AdminAuditController.kt`, `LegacyAssetsController.kt`. - `services/gateway/.../JwtAuthenticationFilter.kt`. -- `scripts/ldd-*.sh`. +- `scripts/ldd-*.sh`, `scripts/ci-preflight.sh`, `scripts/legacy-assets.sh`. - `deploy/backup.sh`, `deploy/deploy.sh`, `deploy/DEPLOY.md`. diff --git a/docs/README.md b/docs/README.md index 0ec952d..c716177 100644 --- a/docs/README.md +++ b/docs/README.md @@ -14,7 +14,7 @@ Preview Mermaid Support) и в IntelliJ IDEA (плагин Mermaid). | [04-sequences.md](04-sequences.md) | Диаграммы последовательностей: вход, импорт ЛР, раздача вариантов, сдача, защита, оценки, материалы | | [05-states.md](05-states.md) | Диаграммы состояний: защита, публикация оценок, статус ЛР, публикация материалов | | [06-authoring.md](06-authoring.md) | Работа с учебными материалами: структура, markdown-редактор, импорт zip | -| [07-groups-courses.md](07-groups-courses.md) | ТЗ: группы/потоки, курсы и преподаватели, повышение курса, академический отпуск, интеграция с «Контингент» | +| [07-groups-courses.md](07-groups-courses.md) | ТЗ: группы/потоки, курсы и преподаватели, повышение курса, академический отпуск; «Контингент» исключён из плана | | [08-groups-courses-plan.md](08-groups-courses-plan.md) | План реализации ТЗ 07: решения, этапы 0–6, профиль студента курса, агенты и skills | | [09-teacher-student-profile.md](09-teacher-student-profile.md) | Контракт API профиля студента курса для преподавателя | | [10-stage0-report.md](10-stage0-report.md) | Отчёт этапа 0: миграции, preflight и проверка на копии БД | @@ -26,7 +26,7 @@ Preview Mermaid Support) и в IntelliJ IDEA (плагин Mermaid). | [16-stage6-report.md](16-stage6-report.md) | Отчёт этапа 6: права преподавателя на запись, аудит, CI, upgrade-тест миграций, security-матрица, чек-лист деплоя и остаточные ограничения | | [17-handoff.md](17-handoff.md) | Handoff: что уже внесено, что осталось, как восстановить контекст и прогнать проверки | | [evidence/README.md](evidence/README.md) | LDD: реестр утверждений, evidence gate, runtime- и migration-проверки | -| [evidence/baseline-2026-10-27.md](evidence/baseline-2026-10-27.md) | LDD-baseline: 46 claims, 319 тестов, upgrade, runtime/scale, mutation и browser E2E | +| [evidence/baseline-2026-10-27.md](evidence/baseline-2026-10-27.md) | LDD-baseline: 51 claims, 346 тестов, upgrade, runtime/scale, mutation и browser E2E | | [ADR 0001](adr/0001-groups-courses-foundation.md) | Ключевые архитектурные решения: курс C, миграции, доступ, статусы, повышение | ## Кратко о системе diff --git a/docs/adr/0001-groups-courses-foundation.md b/docs/adr/0001-groups-courses-foundation.md index 7e7372a..649d9c4 100644 --- a/docs/adr/0001-groups-courses-foundation.md +++ b/docs/adr/0001-groups-courses-foundation.md @@ -92,8 +92,9 @@ - `ACADEMIC_LEAVE` — вход в режиме только чтения. - Web перепроверяет статус с кэшем 30–60 секунд; сервисы проверяют статус студента и группы при доступе к курсу. -- JWT остаётся 30-дневным; остаточный риск прямого API фиксируется в - документации. Для браузера токен не доступен. +- ~~JWT остаётся 30-дневным~~ — решение пересмотрено: access 15 минут, + refresh 7 дней с ротацией и абсолютным потолком 30 дней (`V11`–`V12`). + Для браузера токен не доступен. ### 6. Повышение курса diff --git a/docs/evidence/README.md b/docs/evidence/README.md index 4448f9a..56d7ff9 100644 --- a/docs/evidence/README.md +++ b/docs/evidence/README.md @@ -52,14 +52,14 @@ # 6. Mutation testing критичных P0-компонентов ./scripts/ldd-mutation.sh -# 7. Браузерный E2E (Playwright + Chromium) +# 7. Браузерный E2E (Playwright: Chromium/Firefox/WebKit) ./scripts/ldd-browser.sh # 8. Полный release gate ./scripts/ldd-release.sh ``` -Для E2E один раз установите зависимости и браузер: `cd e2e && npm install && npx playwright install chromium`. +Для E2E один раз установите зависимости и браузеры: `cd e2e && npm install && npx playwright install chromium firefox webkit`. Mutation harness использует JUnit 4: JUnit5-плагин PIT несовместим с JUnit Platform 6 из Spring Boot 4. @@ -87,15 +87,17 @@ Platform 6 из Spring Boot 4. LDD-циклы выполнены 2026-10-27/28; последний — после закрытия продуктовых рисков: -- **319 автоматических тестов**, 0 failures/errors; -- **46 claims**: 25 P0 + 21 P1, **46/46 proven, P0 gaps = 0**. P0 — доступы, +- **346 автоматических тестов**, 0 failures/errors; +- **51 claims**: 25 P0 + 26 P1, **51/51 proven, P0 gaps = 0**. P0 — доступы, повышение, жизненный цикл, профиль/скачивание, материалы/активы - (включая secure-by-default legacy-файлы), миграция V13, аудит, refresh-токены - и их отзыв; P1 — группы, авторство, UI, контракты, импорт, копирование лаб, - пагинация аудита, bulk-группы курса, runtime, mutation и browser E2E; + (включая secure-by-default legacy-файлы), миграции V11–V13, аудит, + refresh-токены и их отзыв; P1 — группы, авторство, UI, контракты, импорт, + копирование лаб, keyset-пагинация, пакетные детали лабораторных, + абсолютный потолок сессии, runtime, mutation и browser E2E; - mutation testing: `CourseAccessService` 75% test strength, `PromotionPlanner` 69%; -- Playwright: 4 сценария (admin, teacher, student/anonymous, сдача+mobile), +- Playwright: 6 сценариев (admin, teacher, student/anonymous, сдача+mobile, + cursor-аудит, инвентаризация активов) × Chromium/Firefox/WebKit = **18/18**, все зелёные; - масштабный load: 100 групп / 1000 студентов, 125 параллельных чтений, 0 ошибок, p95 < 1 с (порог p95 < 2 с); @@ -107,7 +109,9 @@ LDD-циклы выполнены 2026-10-27/28; последний — посл Kotlin-модуль, поэтому DTO с default-значениями требовали все поля JSON (например, создание студента без `firstName`/`basis` давало 400). -Остаточные ограничения: offset-пагинация аудита, N+1 в отдельных лабораторных -списках/ведомости, отсутствие абсолютного лимита web-сессии. Legacy-активы, -срок жизни JWT и «ведущие нули» больше не являются открытыми рисками. +Остаточные ограничения: ручная инвентаризация legacy-активов на production, +сдачи по выбранной лабораторной в ведомости, непроверенный первый CI-прогон на +реальных runner'ах и отсутствие визуальной регрессии. Legacy-активы, срок +access-токена, пагинация, N+1 деталей лабораторных, абсолютный лимит сессии и +«ведущие нули» больше не являются открытыми рисками. Результаты — в `baseline-2026-10-27.md`. diff --git a/docs/evidence/baseline-2026-10-27.md b/docs/evidence/baseline-2026-10-27.md index fab7220..1838b4a 100644 --- a/docs/evidence/baseline-2026-10-27.md +++ b/docs/evidence/baseline-2026-10-27.md @@ -7,13 +7,13 @@ Playwright. В ходе проверки найден и исправлен ре ## 1. Что проверялось -Реестр: `docs/evidence/claims.json`, **44 утверждения**. +Реестр: `docs/evidence/claims.json`, **51 утверждение**. | Критичность | Claims | Proven | Gaps | |---|---:|---:|---:| | P0 | 25 | 25 | 0 | -| P1 | 21 | 21 | 0 | -| **Итого** | **46** | **46** | **0** | +| P1 | 26 | 26 | 0 | +| **Итого** | **51** | **51** | **0** | Домены: @@ -23,12 +23,13 @@ Playwright. В ходе проверки найден и исправлен ре - жизненный цикл: академ, возврат, отчисление, архив, восстановление, вход; - профиль студента и скачивание сдач; - материалы и активы, включая secure-by-default legacy-файлы; -- миграции V11 и V13, полный upgrade с master-схемы; +- миграции V11–V13, полный upgrade с master-схемы; - аудит: токен, admin-only, сохранность после удаления, representative-хуки, - пагинация; -- аутентификация: короткий access-токен, ротация/отзыв refresh, web-сессия; -- группы курса без N+1, авторство материалов, ведомость, UI/контракты, импорт, - копирование лабораторных; + keyset-пагинация; +- аутентификация: короткий access-токен, ротация/отзыв refresh, web-сессия, + абсолютный потолок 30 дней и очистка истёкших токенов; +- группы курса и детали лабораторных без N+1, авторство материалов, ведомость, + UI/контракты, импорт, копирование лабораторных; - runtime-smoke, масштабный load, mutation testing, браузерный E2E и CI smoke. ## 2. Команды и результаты @@ -39,7 +40,7 @@ Playwright. В ходе проверки найден и исправлен ре ./gradlew test --rerun-tasks ``` -Результат: **319 тестов, 0 failures, 0 errors, 0 skipped**. +Результат: **346 тестов, 0 failures, 0 errors, 0 skipped**. `ktlintCheck` и `detekt` — зелёные. ### 2.2. LDD gate @@ -48,7 +49,7 @@ Playwright. В ходе проверки найден и исправлен ре ./gradlew evidenceCheck ``` -Результат: **46/46 claims proven, P0 gaps = 0**. +Результат: **51/51 claims proven, P0 gaps = 0**. Отчёт: `build/reports/evidence/index.md`. Gate устроен так: @@ -67,11 +68,13 @@ Gate устроен так: Сценарий: одноразовая PostgreSQL 18, master identity `V1–V3`, academic `V1–V5`, progress `V1–V9,V11,V12`, синтетические данные, затем ветка: -identity `V4–V11`, academic `V6–V12`, progress `V13`. +identity `V4–V13`, academic `V6–V12`, progress `V13`. Проверено: -- `identity.audit_events` (`V10`) и `identity.refresh_tokens` (`V11`) созданы; +- `identity.audit_events` (`V10`), `identity.refresh_tokens` (`V11`), + `absolute_expires_at` (`V12`) и keyset-индекс `audit_events_occurred_id_idx` + (`V13`) созданы; - `students`, `groups`, `membership_history` сохранились; - `labs.course_id`, `chapters.course_id`, `article_assets` созданы; - `grade_comments.author_account_id`, `lab_grades.updated_by_account_id` созданы; @@ -117,7 +120,8 @@ identity `V4–V11`, academic `V6–V12`, progress `V13`. преподавателю (существование не раскрывается); - админ может скачать такой файл; - `GET /api/v1/content/legacy-assets` защищён `ROLE_ADMIN` и перечисляет - несвязанные файлы для привязки или удаления; + несвязанные файлы; админская страница `/admin/legacy-assets` и + `scripts/legacy-assets.sh` дают тот же список для привязки или удаления; - связанный актив по-прежнему наследует видимость статьи → раздела → главы: публичная `ALL` — без входа, курсовая — только участникам курса, скрытая для студента — `404`. @@ -138,30 +142,44 @@ identity `V4–V11`, academic `V6–V12`, progress `V13`. токена отзывает все сессии аккаунта; - logout и смена пароля отзывают refresh; отчисленный, выпущенный, архивный студент, архивный аккаунт и закрытая группа не могут обновить сессию; -- web-сессия — 7 дней sliding (`LMS_SESSION_TIMEOUT`), cookie `LMS_SESSION` - (`HttpOnly`, `SameSite=Lax`, `Max-Age` перевыпускается на каждом ответе); +- `V12__refresh_absolute.sql` задаёт `absolute_expires_at`; ротация не + продлевает абсолютный срок, после 30 дней refresh отклоняется; +- web-сессия — 7 дней sliding (`LMS_SESSION_TIMEOUT`), но не дольше 30 дней + (`LMS_SESSION_ABSOLUTE_TIMEOUT`); cookie `LMS_SESSION` + (`HttpOnly`, `SameSite=Lax`, `Max-Age` перевыпускается на каждом ответе и + ограничен остатком абсолютного срока); - access обновляется автоматически за 2 минуты до истечения, поэтому активная - вкладка не требует входа каждые 15 минут; logout отзывает refresh. + вкладка не требует входа каждые 15 минут; logout отзывает refresh; +- истёкшие refresh-токены старше retention (30 дней) удаляет ночная задача + (`LMS_REFRESH_TOKEN_CLEANUP_CRON`), свежие отозванные токены сохраняются для + detection reuse. Прямой API-клиент должен сам использовать `POST /api/v1/auth/refresh`; автоматического обновления вне web BFF нет. -Результат: **PASS**, claims `LDD-AUTH-001`, `LDD-AUTH-002`, `LDD-AUTH-003`. +Результат: **PASS**, claims `LDD-AUTH-001`, `LDD-AUTH-002`, `LDD-AUTH-003`, +`LDD-AUTH-006`, `LDD-AUTH-007`. ### 2.7. Пагинация, N+1 и масштаб -- `GET /api/v1/audit` принимает `page` и `limit` (максимум 500); в UI есть - «Показать ещё». Это offset-пагинация, а не keyset. +- `GET /api/v1/audit` принимает keyset-курсор `cursor` (opaque `occurredAt|id`), + `limit` (максимум 500) и `action`; offset `page` остался для совместимости. + Курсорные страницы не пересекаются, битый курсор — `400`; в UI «Показать ещё» + идёт по курсору, «В начало» возвращает к первой странице. +- `GET /api/v1/labs/details` отдаёт все лабораторные курса с уровнями и + вариантами одним запросом; progress делает ровно один HTTP-вызов вместо + запроса на каждую лабораторную. - `GET /api/v1/courses/{id}/groups` возвращает привязанные группы с именами одним вызовом; страница преподавателя не делает N+1 по именам групп. - Полный load использует seed на **100 групп / 1000 студентов** (SQL, только release-контур) и **125 параллельных чтений** курса, лаб, профиля, групп и аудита: **0 ошибок, p95 < 1 с** (порог p95 < 2 с). -Остаются offset-пагинация аудита и N+1 в отдельных лабораторных -списках/ведомости — это лабораторные детали, не продуктовые риски доступа. +Остаётся запрос сдач по выбранной лабораторной в ведомости — это не N+1 по +студентам и не продуктовый риск доступа. -Результат: **PASS**, claims `LDD-AUDIT-004`, `LDD-PERF-001`, `LDD-RUNTIME-003`. +Результат: **PASS**, claims `LDD-AUDIT-004`, `LDD-AUDIT-005`, `LDD-PERF-001`, +`LDD-PERF-002`, `LDD-RUNTIME-003`. ### 2.8. Mutation testing @@ -187,16 +205,20 @@ PIT запускается через CLI с JUnit 4 harness: JUnit5-плаги ./scripts/ldd-browser.sh ``` -Playwright/Chromium на полном стеке, одноразовая БД: +Playwright в Chromium, Firefox и WebKit на полном стеке, одноразовая БД: - admin: вход, `/admin/courses` с курсом, `/admin/audit` с событием; - teacher: вход, курс, ведомость (группа/студент, без админских кнопок), профиль студента, создание главы курса; - anonymous: `/admin/audit` → `/login`; -- student: вход, `/admin/audit` → `/student`, `/learn/course/{no}`. +- student: вход, `/admin/audit` → `/student`, `/learn/course/{no}`; +- admin: cursor-пагинация аудита («Показать ещё» → «Продолжение» → «В начало»); +- admin: страница `/admin/legacy-assets` со списком или пустым состоянием. -Результат: **4/4 passed**, evidence: `build/evidence/browser.json`. -Дополнительно проверены сдача файла студентом и mobile-размер teacher-страницы. +Результат: **18/18 passed** (6 сценариев × 3 браузера), evidence: +`build/evidence/browser.json`. Дополнительно проверены сдача файла студентом и +mobile-размер teacher-страницы. На Fedora WebKit запускается через Docker-образ +Playwright, потому что системных Ubuntu-библиотек нет. ### 2.10. Конкурентные apply/rollback @@ -215,8 +237,12 @@ mutation, runtime + scale и browser E2E, затем финальный `evidenc - `check` — `test ktlintCheck detekt evidenceCheck` на push/MR; - `smoke` — `ldd-smoke.sh` (полный стек, health, вход админа, один курс) на каждом push/MR; -- `release-evidence` — полный `ldd-release.sh` с Chromium/Node: обязателен для - тегов, для веток запускается вручную. +- `release-evidence` — полный `ldd-release.sh` с Chromium/Firefox/WebKit и + Node: обязателен для тегов, для веток запускается вручную. +- smoke/release начинаются с `scripts/ci-preflight.sh`, который проверяет + Java 21, Docker Compose v2, Python 3, Node и Playwright. +- Первый реальный прогон на GitLab shell-runners ещё не подтверждён; локальные + скрипты и конфигурация проверены. ### 2.12. Найденный дефект @@ -240,9 +266,12 @@ optional json fields`. - Legacy-актив без привязки не утекает обычным пользователям, а админ может провести инвентаризацию. - Refresh-токены ротируются и отзываются; закрытый статус блокирует продление - сессии, а web-сессия переживает 15-минутный access-токен. -- Аудит листается по страницам, а группы курса отдаются одним вызовом без N+1. -- Аудит пишется и читается; upgrade не теряет синтетические данные. + сессии, абсолютный потолок 30 дней ограничивает сессию, а web-сессия + переживает 15-минутный access-токен. +- Аудит листается keyset-курсором, детали лабораторных и группы курса + отдаются пакетно без N+1. +- Аудит пишется и читается; upgrade не теряет синтетические данные; + истёкшие refresh-токены очищаются. - Мутационная прочность критичных компонентов измерена, а не заявлена. - Масштабный load на 100 группах / 1000 студентов проходит без ошибок. - Реальный дефект DTO/Jackson найден именно E2E-контуром и закрыт тестом. @@ -254,35 +283,36 @@ optional json fields`. 2. **Property-based тесты покрывают не все ветки.** Есть генераторы для матрицы доступа, планировщика и 10 транзакционных apply/rollback-кейсов, но не для всех сочетаний полей и распределённой нагрузки. -3. **Браузерный E2E — один Chromium и 4 сценария.** Нет визуальной регрессии, - Safari/Firefox и полного мобильного набора страниц. +3. **Браузерный E2E — 3 браузера, 6 сценариев.** Нет визуальной регрессии и + полного мобильного набора страниц; WebKit на Fedora требует Docker-образа. 4. **Нагрузочный smoke — не benchmark.** Одна машина, синтетические данные, без SLA production; 125 чтений и p95 < 1 с — ориентир, а не гарантия. -5. **Offset-пагинация аудита.** Вставки между страницами могут сдвигать - выдачу; keyset-курсор не реализован. -6. **N+1 в отдельных лабораторных списках/ведомости.** Имена групп курса и - аудит исправлены, но не все большие списки собраны пакетно. -7. **Нет абсолютного лимита web-сессии.** Sliding-сессия может продлеваться - бесконечно при непрерывной активности; отзыв по статусу при этом работает. +5. **Сдачи по выбранной лабораторной в ведомости** запрашиваются отдельно; + это не N+1 по студентам, но и не пакетная выборка. +6. **Инвентаризация legacy-активов на production не выполнена.** UI, API и + скрипт готовы, но реальные файлы нужно привязать или удалить вручную. +7. **Первый CI-прогон не подтверждён.** Конфигурация и preflight готовы, но + наличие shell-runners `lms-shell`/`lms-release` проверяется в GitLab. 8. **Полный release-evidence не на каждом коммите.** На push/MR идёт smoke, а mutation/browser/scale — для тегов или вручную. -9. **Внешняя интеграция «Контингент» не проверялась** — она остаётся заделом. +9. **Интеграция «Контингент» исключена из плана** — без нового ТЗ и доступа к + API она не проверялась и не реализуется. 10. **Номер группы числовой.** Отдельного требования к ведущим нулям нет; `01` и `1` — одно значение, это не открытый риск. ## 5. Вывод -Текущая функциональность прошла LDD-контур: **46/46 claims proven, P0 gaps = 0**, -**319 тестов**, upgrade, mutation baseline, browser E2E, smoke и масштабный load -зелёные. Это достаточное основание продолжать доработки, но не «формальное -доказательство всей системы». +Текущая функциональность прошла LDD-контур: **51/51 claims proven, P0 gaps = 0**, +**346 тестов**, upgrade, mutation baseline, 18/18 browser E2E, smoke и +масштабный load зелёные. Это достаточное основание продолжать доработки, но не +«формальное доказательство всей системы». Следующий цикл LDD: -1. почистить инвентарь legacy-активов через `GET /api/v1/content/legacy-assets` - (файлы уже не публичны, это гигиена хранилища); -2. добавить keyset-пагинацию аудита и убрать оставшийся N+1 в лабораторных - списках/ведомости; -3. при необходимости ввести абсолютный лимит web-сессии; -4. расширить browser E2E на публикацию оценки и остальные мобильные страницы; -5. подготовить внешнюю интеграцию «Контингент»/`next.uniyar.ac.ru`. +1. прогнать инвентаризацию legacy-активов на production/staging через + `/admin/legacy-assets` или `scripts/legacy-assets.sh` и привязать/удалить + найденные файлы; +2. подтвердить первый реальный CI-прогон на shell-runners и tag-job; +3. при необходимости расширить E2E на визуальную регрессию и остальные + мобильные страницы; +4. пакетно отдавать сдачи в ведомости, если появятся медленные курсы. diff --git a/docs/evidence/claims.json b/docs/evidence/claims.json index 6257672..9a5a078 100644 --- a/docs/evidence/claims.json +++ b/docs/evidence/claims.json @@ -435,7 +435,7 @@ "criticality": "P1", "status": "active", "requirement": "docs/16-stage6-report.md, §2.2", - "claim": "Схема master + синтетические данные → миграции identity V4–V11, academic V6–V12, progress V13; V10 audit и V11 refresh создаются, V13 идемпотентна.", + "claim": "Схема master + синтетические данные → миграции identity V4–V13, academic V6–V12, progress V13; V10 audit, V11 refresh, V12 absolute-expiry и V13 keyset-индексы создаются, V13 идемпотентна.", "oracle": "Успешное применение и postflight-наличие таблиц/колонок/индексов", "runtime": [ "migration.master_upgrade" @@ -488,16 +488,16 @@ }, { "id": "LDD-BROWSER-001", - "title": "Браузерный E2E админа, преподавателя и студента, включая сдачу и mobile", + "title": "Браузерный E2E в Chromium, Firefox и WebKit", "criticality": "P1", "status": "active", "requirement": "docs/evidence/README.md", - "claim": "Playwright на реальном стеке: admin видит курс и аудит; teacher открывает курс/ведомость/профиль, создаёт главу и видит mobile-вид; student сдаёт файл; аноним и студент не попадают в админку.", + "claim": "6 сценариев (админ, преподаватель, студент/аноним, сдача+mobile, cursor-аудит, инвентаризация активов) проходят в Chromium, Firefox и WebKit: 18/18 без unexpected.", "oracle": "Playwright assertions на DOM/URL", "runtime": [ "browser.e2e" ], - "limitations": "4 сценария, один Chromium; нет визуальной регрессии и Safari/Firefox." + "limitations": "Нет визуальной регрессии и полного набора мобильных страниц; WebKit на Fedora запускается через Docker-образ Playwright." }, { "id": "LDD-RUNTIME-002", @@ -613,7 +613,7 @@ "ru.ysu.lms.web.session.SessionCookieRefreshFilterTest#refreshes cookie max age on every existing session()", "ru.ysu.lms.web.auth.AuthPageControllerTest#logout revokes refresh token and invalidates session()" ], - "limitations": "Cookie и серверный timeout оба 7 дней; абсолютного верхнего предела сессии нет." + "limitations": "Cookie и серверный timeout 7 дней; поверх них действует абсолютный потолок 30 дней (LDD-AUTH-006)." }, { "id": "LDD-AUDIT-004", @@ -621,13 +621,13 @@ "criticality": "P1", "status": "active", "requirement": "docs/evidence/README.md", - "claim": "Audit API отдаёт страницы limit/page без пересечений и с устойчивым порядком; UI показывает «Показать ещё».", + "claim": "Audit API поддерживает offset-страницы `page`/`limit` для совместимости; UI по умолчанию продолжает журнал keyset-курсором.", "oracle": "Страницы AuditIntegrationTest и ссылки шаблона", "evidence": [ "ru.ysu.lms.identity.audit.AuditIntegrationTest#pagination returns disjoint pages newest first()", "ru.ysu.lms.web.admin.AdminAuditTemplateTest#renders audit events with labels and filter()" ], - "limitations": "Offset-пагинация, не keyset; при частых вставках возможны сдвиги." + "limitations": "Offset-режим остаётся только для старых ссылок; основной путь — курсор (LDD-AUDIT-005)." }, { "id": "LDD-PERF-001", @@ -641,7 +641,7 @@ "ru.ysu.lms.academic.course.CourseGroupTeacherAccessTest#teacher gets attached groups with names in one call()", "ru.ysu.lms.web.teacher.TeacherControllerCourseGroupsTest#loads group names with a single call instead of per-group requests()" ], - "limitations": "Детали лабораторных профиля всё ещё подтягиваются по одной; это следующий шаг." + "limitations": "Детали лабораторных теперь пакетные (LDD-PERF-002); отдельные запросы на лабораторную в ведомости ещё остаются." }, { "id": "LDD-AUTH-004", @@ -669,6 +669,84 @@ "runtime.auth_refresh" ], "limitations": "Проверено на одноразовом стеке; TTL не истекает в рамках smoke." + }, + { + "id": "LDD-AUTH-006", + "title": "Абсолютный потолок сессии 30 дней", + "criticality": "P1", + "status": "active", + "requirement": "docs/16-stage6-report.md, §4", + "claim": "Ротация refresh не продлевает absolute_expires_at; после 30 дней refresh отклоняется, а web-сессия принудительно завершается и cookie не живёт дольше остатка.", + "oracle": "absolute_expires_at в цепочке ротаций, 401 на refresh, redirect /login?expired=absolute, Max-Age cookie", + "evidence": [ + "ru.ysu.lms.identity.RefreshTokenIntegrationTest#refresh past the absolute session expiry is rejected with unauthorized()", + "ru.ysu.lms.identity.RefreshTokenIntegrationTest#rotation keeps the absolute session expiry unchanged()", + "ru.ysu.lms.web.session.AuthInterceptorTest#absolute expired session redirects to absolute login without identity call()", + "ru.ysu.lms.web.session.SessionCookieRefreshFilterTest#cookie max age is capped by remaining absolute lifetime()", + "ru.ysu.lms.web.session.SessionCookieRefreshFilterTest#absolute expired session receives zero max age cookie()", + "ru.ysu.lms.web.auth.AuthPageControllerTest#login stores authenticated at timestamp()" + ], + "limitations": "Срок настраивается LMS_SESSION_ABSOLUTE_TIMEOUT; абсолютный отсчёт начинается с логина, а не с создания web-сессии." + }, + { + "id": "LDD-AUTH-007", + "title": "Очистка истёкших refresh-токенов", + "criticality": "P1", + "status": "active", + "requirement": "docs/16-stage6-report.md, §4", + "claim": "Ночная очистка удаляет токены, у которых sliding- или absolute-срок истёк раньше retention (30 дней), и сохраняет свежие/недавно отозванные токены для detection reuse.", + "oracle": "cleanupExpired(now) и количество удалённых записей", + "evidence": [ + "ru.ysu.lms.identity.RefreshTokenIntegrationTest#cleanup deletes only tokens expired beyond retention()" + ], + "limitations": "Расписание задаётся LMS_REFRESH_TOKEN_CLEANUP_CRON; в тесте вызывается метод напрямую, без ожидания cron." + }, + { + "id": "LDD-AUDIT-005", + "title": "Keyset-пагинация аудита", + "criticality": "P1", + "status": "active", + "requirement": "docs/16-stage6-report.md, §2.7", + "claim": "Audit API принимает непрозрачный cursor (occurredAt|id), отдаёт строго следующие записи без пересечений и сохраняет фильтр по action; битый курсор — 400; UI ведёт «Показать ещё» по курсору.", + "oracle": "Страницы AuditIntegrationTest и cursor-ссылка шаблона", + "evidence": [ + "ru.ysu.lms.identity.audit.AuditIntegrationTest#cursor pagination walks all events newest first()", + "ru.ysu.lms.identity.audit.AuditIntegrationTest#cursor pagination respects the action filter()", + "ru.ysu.lms.identity.audit.AuditIntegrationTest#malformed audit cursor is rejected with bad request()", + "ru.ysu.lms.identity.audit.AuditIntegrationTest#cursor from the newest event returns an empty page()", + "ru.ysu.lms.web.admin.AdminAuditTemplateTest#cursor mode links next page with cursor and resets to start()" + ], + "limitations": "Offset page остаётся для обратной совместимости; keyset-курсор не подписан, но не содержит секретов." + }, + { + "id": "LDD-PERF-002", + "title": "Пакетные детали лабораторных без N+1", + "criticality": "P1", + "status": "active", + "requirement": "docs/16-stage6-report.md, §2.7", + "claim": "Academic отдаёт все лабораторные курса с уровнями и вариантами одним запросом /api/v1/labs/details; progress делает ровно один HTTP-вызов вместо запроса на каждую лабораторную.", + "oracle": "LabDetailsIntegrationTest и счётчик запросов AcademicClientBatchLabsTest", + "evidence": [ + "ru.ysu.lms.academic.lab.LabDetailsIntegrationTest#teacher gets all course labs with levels and variants in one call()", + "ru.ysu.lms.academic.lab.LabDetailsIntegrationTest#student of the course receives lab details()", + "ru.ysu.lms.academic.lab.LabDetailsIntegrationTest#foreign teacher is forbidden for lab details()", + "ru.ysu.lms.progress.AcademicClientBatchLabsTest#getCourseLabs loads all labs with levels and variants in one request()" + ], + "limitations": "Ведомость всё ещё запрашивает сдачи по выбранной лабораторной; это не N+1 по студентам." + }, + { + "id": "LDD-ASSET-003", + "title": "Админская инвентаризация legacy-активов", + "criticality": "P1", + "status": "active", + "requirement": "docs/15-stage5-course-materials.md", + "claim": "Админ открывает /admin/legacy-assets, видит список файлов без статьи, ссылку на файл и пустое состояние; страница не показывает ошибку при пустом списке.", + "oracle": "LegacyAssetsTemplateTest и HTTP-страница", + "evidence": [ + "ru.ysu.lms.web.admin.LegacyAssetsTemplateTest#renders orphan assets with links and admin-only explanation()", + "ru.ysu.lms.web.admin.LegacyAssetsTemplateTest#renders empty legacy assets state()" + ], + "limitations": "Удаление файлов остаётся ручной операцией на стороне academic-service; UI только инвентаризирует." } ] } \ No newline at end of file diff --git a/e2e/lms.spec.ts b/e2e/lms.spec.ts index 6d8fd8e..ccb2769 100644 --- a/e2e/lms.spec.ts +++ b/e2e/lms.spec.ts @@ -204,3 +204,39 @@ test("student submits a solution and mobile teacher page renders", async ({ page await expect(page.locator("h1")).toContainText(seeded.courseTitle); await expect(page.locator("body")).toContainText(seeded.groupName); }); + +test("admin paginates audit with cursor and returns to start", async ({ page }) => { + await login(page, seeded.adminLogin, seeded.adminPassword, "**/admin"); + + await page.goto("/admin/audit?limit=1"); + await expect(page.locator("body")).toContainText("Страница 1"); + const nextLink = page.getByRole("link", { name: "Показать ещё" }); + await expect(nextLink).toBeVisible(); + await expect(nextLink).toHaveAttribute("href", /cursor=/); + + await nextLink.click(); + await expect(page).toHaveURL(/cursor=/); + await expect(page.locator("body")).toContainText("Продолжение"); + await expect(page.getByRole("link", { name: "В начало" })).toBeVisible(); + + await page.getByRole("link", { name: "Показать ещё" }).click(); + await expect(page).toHaveURL(/cursor=/); + await expect(page.locator("body")).toContainText("Продолжение"); + await expect(page.getByRole("link", { name: "В начало" })).toBeVisible(); + + await page.getByRole("link", { name: "В начало" }).click(); + await expect(page).not.toHaveURL(/cursor=/); + await expect(page.locator("body")).toContainText("Страница 1"); +}); + +test("admin opens legacy assets inventory", async ({ page }) => { + await login(page, seeded.adminLogin, seeded.adminPassword, "**/admin"); + await page.goto("/admin/legacy-assets"); + + await expect(page.locator("h1")).toContainText("Активы"); + await expect(page.locator("body")).toContainText("только администраторам"); + const assetRow = page.locator("table tbody tr td.font-mono").first(); + const empty = page.getByText("Бесхозных файлов нет."); + await expect(assetRow.or(empty)).toBeVisible(); + await expect(page.locator(".toast-error")).toHaveCount(0); +}); diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts index 486d8bb..a10e637 100644 --- a/e2e/playwright.config.ts +++ b/e2e/playwright.config.ts @@ -23,5 +23,13 @@ export default defineConfig({ name: "chromium", use: { ...devices["Desktop Chrome"] }, }, + { + name: "firefox", + use: { ...devices["Desktop Firefox"] }, + }, + { + name: "webkit", + use: { ...devices["Desktop Safari"] }, + }, ], }); diff --git a/scripts/ci-preflight.sh b/scripts/ci-preflight.sh new file mode 100755 index 0000000..c4fdaad --- /dev/null +++ b/scripts/ci-preflight.sh @@ -0,0 +1,111 @@ +#!/usr/bin/env bash +# Проверка окружения shell-runner перед smoke/release. Ничего не устанавливает. +set -euo pipefail + +cd "$(dirname "$0")/.." + +release=0 +case "${1:-}" in + "") ;; + --release) release=1 ;; + -h | --help) + echo "Usage: $0 [--release]" + exit 0 + ;; + *) + echo "Usage: $0 [--release]" >&2 + exit 2 + ;; +esac + +failures=() + +ok() { printf 'OK %s\n' "$*"; } + +fail() { + printf 'FAIL %s\n' "$*" + failures+=("$*") +} + +if [[ -n "${BASH_VERSION:-}" ]]; then + ok "bash ${BASH_VERSION}" +else + fail "bash: не найден" +fi + +if command -v java >/dev/null 2>&1; then + java_line=$(java -version 2>&1 | head -n 1 || true) + if [[ "$java_line" =~ version\ \"21([.\"-]|$) ]]; then + ok "java 21: ${java_line:-java}" + else + fail "java: нужна версия 21, найдено: ${java_line:-неизвестно}" + fi +else + fail "java: не найден в PATH" +fi + +if command -v docker >/dev/null 2>&1; then + docker_line=$(docker --version 2>/dev/null | head -n 1 || true) + ok "docker: ${docker_line:-команда доступна}" +else + fail "docker: не найден в PATH" +fi + +if command -v docker >/dev/null 2>&1; then + compose_short=$(docker compose version --short 2>/dev/null | head -n 1 || true) + if [[ -z "$compose_short" ]]; then + compose_short=$(docker compose version 2>/dev/null | head -n 1 || true) + compose_short=${compose_short#Docker Compose version } + compose_short=${compose_short#v} + fi + compose_short=${compose_short#v} + compose_major=${compose_short%%.*} + if [[ "$compose_major" =~ ^[0-9]+$ ]] && ((compose_major >= 2)); then + ok "docker compose v2+: ${compose_short:-неизвестно}" + else + fail "docker compose: нужен v2+, найдено: ${compose_short:-версия не определена}" + fi +else + fail "docker compose: docker не найден" +fi + +if command -v python3 >/dev/null 2>&1; then + ok "python3: $(python3 --version 2>&1 | head -n 1)" +else + fail "python3: не найден в PATH" +fi + +if ((release)); then + if command -v node >/dev/null 2>&1; then + ok "node: $(node --version 2>&1 | head -n 1)" + else + fail "node: не найден в PATH" + fi + + if command -v npx >/dev/null 2>&1; then + ok "npx: $(npx --version 2>&1 | head -n 1)" + else + fail "npx: не найден в PATH" + fi + + if [[ -d e2e/node_modules ]]; then + ok "e2e/node_modules: найден" + playwright_line=$(cd e2e && npx playwright --version 2>/dev/null | head -n 1 || true) + if [[ -n "$playwright_line" ]]; then + ok "playwright: $playwright_line" + else + fail "npx playwright --version: не удалось получить версию" + fi + else + fail "e2e/node_modules: не найден (выполните: cd e2e && npm ci)" + fail "npx playwright --version: пропущено без e2e/node_modules" + fi +fi + +echo +if ((${#failures[@]} > 0)); then + echo "Preflight не пройден, ошибок: ${#failures[@]}." + printf ' - %s\n' "${failures[@]}" + exit 1 +fi +echo "Preflight пройден." diff --git a/scripts/ldd-browser.sh b/scripts/ldd-browser.sh index 29ee333..9991510 100755 --- a/scripts/ldd-browser.sh +++ b/scripts/ldd-browser.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# LDD browser E2E: полный стек + Playwright (Chromium) на одноразовой БД. +# LDD browser E2E: полный стек + Playwright (Chromium/Firefox/WebKit) на одноразовой БД. set -euo pipefail cd "$(dirname "$0")/.." @@ -11,6 +11,47 @@ E2E_STATUS=1 log() { echo ">> $*"; } +playwright_version() { + (cd e2e && node -p "require('@playwright/test/package.json').version" 2>/dev/null || true) +} + +host_playwright_usable() { + # WebKit на Fedora часто не запускается из-за Ubuntu-специфичных libicu/libjpeg; + # проверяем все три браузера, чтобы fallback сработал и при нехватке lib для Firefox. + (cd e2e && node -e ' + const { chromium, firefox, webkit } = require("playwright"); + (async () => { + for (const type of [chromium, firefox, webkit]) { + const browser = await type.launch(); + await browser.close(); + } + })().catch(() => process.exit(1)); + ') >/dev/null 2>&1 +} + +ensure_playwright_browsers() { + local browser detected missing=() + # Проверяем точные пути браузеров текущей версии Playwright, а не кэш целиком. + if ! detected=$(cd e2e && node -e ' + const fs = require("fs"); + const { chromium, firefox, webkit } = require("playwright"); + for (const [name, type] of [["chromium", chromium], ["firefox", firefox], ["webkit", webkit]]) { + if (!fs.existsSync(type.executablePath())) console.log(name); + } + ' 2>/dev/null); then + detected=$'chromium\nfirefox\nwebkit' + fi + while IFS= read -r browser; do + [[ -n "$browser" ]] && missing+=("$browser") + done <<<"$detected" + if ((${#missing[@]} == 0)); then + log "Браузеры Playwright уже установлены (Chromium/Firefox/WebKit)." + return + fi + log "Устанавливаю браузеры Playwright: ${missing[*]}; системные зависимости ставит CI." + (cd e2e && npx playwright install --no-progress "${missing[@]}") +} + cleanup() { local code=$? if ((STACK_STARTED)); then @@ -68,15 +109,34 @@ STACK_STARTED=1 ./run.sh start mkdir -p build/evidence -log "Запускаю Playwright..." +ensure_playwright_browsers set +e -( - cd e2e - LDD_WEB_URL="http://localhost:8090" \ - LDD_API_URL="http://localhost:8080" \ - npx playwright test --config=playwright.config.ts -) -E2E_STATUS=$? +if host_playwright_usable; then + log "Запускаю Playwright на хосте (Chromium/Firefox/WebKit)..." + ( + cd e2e + LDD_WEB_URL="http://localhost:8090" \ + LDD_API_URL="http://localhost:8080" \ + npx playwright test --config=playwright.config.ts + ) + E2E_STATUS=$? +else + version=$(playwright_version) + if [[ -z "$version" ]]; then + echo "WebKit не запускается на хосте, а версию Playwright определить не удалось." >&2 + E2E_STATUS=1 + else + log "WebKit не запускается на хосте; запускаю Chromium/Firefox/WebKit в Docker-образе Playwright ${version}." + docker run --rm --network host \ + -v "$PWD:/work" \ + -w /work/e2e \ + -e LDD_WEB_URL="http://localhost:8090" \ + -e LDD_API_URL="http://localhost:8080" \ + "mcr.microsoft.com/playwright:v${version}-noble" \ + npx playwright test --config=playwright.config.ts + E2E_STATUS=$? + fi +fi set -e python3 - "$E2E_STATUS" <<'PY' diff --git a/scripts/ldd-migration-upgrade.sh b/scripts/ldd-migration-upgrade.sh index c37d12f..f4fbf22 100755 --- a/scripts/ldd-migration-upgrade.sh +++ b/scripts/ldd-migration-upgrade.sh @@ -103,6 +103,8 @@ log "Применяю миграции ветки..." cat services/identity-service/src/main/resources/db/migration/V9__account_statuses.sql cat services/identity-service/src/main/resources/db/migration/V10__audit_events.sql cat services/identity-service/src/main/resources/db/migration/V11__refresh_tokens.sql + cat services/identity-service/src/main/resources/db/migration/V12__refresh_absolute.sql + cat services/identity-service/src/main/resources/db/migration/V13__audit_keyset_indexes.sql echo 'SET search_path TO academic;' cat services/academic-service/src/main/resources/db/migration/V6__courses.sql cat services/academic-service/src/main/resources/db/migration/V7__labs_course.sql @@ -122,6 +124,10 @@ SELECT 'audit_events=' || count(*) FROM information_schema.tables WHERE table_schema='identity' AND table_name='audit_events'; SELECT 'refresh_tokens=' || count(*) FROM information_schema.tables WHERE table_schema='identity' AND table_name='refresh_tokens'; +SELECT 'refresh_absolute=' || count(*) FROM information_schema.columns + WHERE table_schema='identity' AND table_name='refresh_tokens' AND column_name='absolute_expires_at'; +SELECT 'audit_keyset_idx=' || count(*) FROM pg_indexes + WHERE schemaname='identity' AND indexname='audit_events_occurred_id_idx'; SELECT 'students=' || count(*) FROM students; SELECT 'groups=' || count(*) FROM groups; SELECT 'history=' || count(*) FROM membership_history; @@ -163,6 +169,8 @@ out, result, v13 = sys.argv[1], sys.argv[2], sys.argv[3] required = { "audit_events=1", "refresh_tokens=1", + "refresh_absolute=1", + "audit_keyset_idx=1", "students=1", "groups=1", "history=1", diff --git a/scripts/ldd-release.sh b/scripts/ldd-release.sh index 3682bc4..e3bcae1 100755 --- a/scripts/ldd-release.sh +++ b/scripts/ldd-release.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash # Полный LDD release gate: тесты, линтеры, upgrade, mutation, runtime и browser E2E. -# Требует Docker, Java 21, Node.js/npm и установленный Chromium Playwright. +# Требует Docker, Java 21, Node.js/npm и Chromium/Firefox/WebKit Playwright. set -euo pipefail cd "$(dirname "$0")/.." diff --git a/scripts/legacy-assets.sh b/scripts/legacy-assets.sh new file mode 100755 index 0000000..3fb8773 --- /dev/null +++ b/scripts/legacy-assets.sh @@ -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 diff --git a/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/controller/LabController.kt b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/controller/LabController.kt index 9b328fa..912fafc 100644 --- a/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/controller/LabController.kt +++ b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/controller/LabController.kt @@ -54,6 +54,16 @@ class LabController( return labService.list(accessible) } + /** Полные данные всех лабораторных курса одним запросом (без N+1 по деталям). */ + @GetMapping("/details") + fun details( + @RequestParam courseId: UUID, + @AuthenticationPrincipal jwt: Jwt, + ): List { + accessService.requireCourseAccess(courseId, jwt) + return labService.details(courseId) + } + @GetMapping("/{id}") fun detail( @PathVariable id: UUID, diff --git a/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/lab/LabService.kt b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/lab/LabService.kt index 4543e99..93dbe0e 100644 --- a/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/lab/LabService.kt +++ b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/lab/LabService.kt @@ -22,7 +22,7 @@ import java.time.OffsetDateTime import java.time.ZoneId import java.util.UUID -@Suppress("LongParameterList") +@Suppress("LongParameterList", "TooManyFunctions") @Service class LabService( private val labs: LabRepository, @@ -118,45 +118,25 @@ class LabService( @Transactional(readOnly = true) fun detail(labId: UUID): LabDetail { val lab = labs.findById(labId).orElseThrow { NotFoundException("Лабораторная не найдена") } - val levels = labLevels.findByLabIdOrderByPosition(lab.id) - return LabDetail( - id = lab.id.toString(), - number = lab.number, - title = lab.title, - status = lab.status, - descriptionMd = lab.descriptionMd, - deadlineAt = lab.deadlineAt?.toString(), - defenseAt = lab.defenseAt?.toString(), - pointsMax = lab.pointsMax, - levels = - levels.map { level -> - // Сортировка по числу в номере: «2» раньше «10» (номер хранится строкой). - val vs = - variantsRepo - .findByLabIdOrderByNumber(lab.id) - .filter { it.labLevel?.id == level.id } - .sortedWith(compareBy({ naturalNumber(it.number) }, { it.number })) - LabLevelDetail( - id = level.id.toString(), - code = level.code, - title = level.title, - variants = - vs.map { v -> - VariantDetail( - id = v.id.toString(), - number = v.number, - title = v.title, - statementMd = v.statementMd, - ) - }, - ) - }, - courseId = lab.course?.id?.toString(), - courseNo = lab.course?.courseNo, - courseTitle = lab.course?.title, + return lab.toDetail( + labLevels.findByLabIdOrderByPosition(lab.id), + variantsRepo.findByLabIdOrderByNumber(lab.id), ) } + /** Все лабораторные курса с уровнями и вариантами: три запроса вместо N+1. */ + @Transactional(readOnly = true) + fun details(courseId: UUID): List { + val labList = labs.findAllByCourseIdOrderByNumberAsc(courseId) + if (labList.isEmpty()) return emptyList() + val labIds = labList.map { it.id } + val levelsByLab = labLevels.findByLabIdInOrderByPosition(labIds).groupBy { it.lab?.id } + val variantsByLab = variantsRepo.findByLabIdInOrderByNumberAsc(labIds).groupBy { it.lab?.id } + return labList.map { lab -> + lab.toDetail(levelsByLab[lab.id].orEmpty(), variantsByLab[lab.id].orEmpty()) + } + } + @Transactional fun delete(labId: UUID) { if (!labs.existsById(labId)) { @@ -414,6 +394,49 @@ private fun Course.rejectArchived(): Course { private fun naturalNumber(value: String): Int = Regex("\\d+").find(value)?.value?.toIntOrNull() ?: Int.MAX_VALUE +/** Единая сборка деталей лабораторной для одиночного и пакетного ответов. */ +private fun Lab.toDetail( + levels: List, + variants: List, +): LabDetail { + val variantsByLevel = variants.groupBy { it.labLevel?.id } + return LabDetail( + id = id.toString(), + number = number, + title = title, + status = status, + descriptionMd = descriptionMd, + deadlineAt = deadlineAt?.toString(), + defenseAt = defenseAt?.toString(), + pointsMax = pointsMax, + levels = + levels.map { level -> + // Сортировка по числу в номере: «2» раньше «10» (номер хранится строкой). + val vs = + variantsByLevel[level.id] + .orEmpty() + .sortedWith(compareBy({ naturalNumber(it.number) }, { it.number })) + LabLevelDetail( + id = level.id.toString(), + code = level.code, + title = level.title, + variants = + vs.map { v -> + VariantDetail( + id = v.id.toString(), + number = v.number, + title = v.title, + statementMd = v.statementMd, + ) + }, + ) + }, + courseId = course?.id?.toString(), + courseNo = course?.courseNo, + courseTitle = course?.title, + ) +} + private fun Lab.toSummary(variants: List): LabSummary = LabSummary( id = id.toString(), diff --git a/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/repo/VariantRepository.kt b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/repo/VariantRepository.kt index d8495ba..5b451e5 100644 --- a/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/repo/VariantRepository.kt +++ b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/repo/VariantRepository.kt @@ -7,8 +7,12 @@ import java.util.UUID interface LabLevelRepository : JpaRepository { fun findByLabIdOrderByPosition(labId: UUID): List + + fun findByLabIdInOrderByPosition(labIds: Collection): List } interface VariantRepository : JpaRepository { fun findByLabIdOrderByNumber(labId: UUID): List + + fun findByLabIdInOrderByNumberAsc(labIds: Collection): List } diff --git a/services/academic-service/src/test/kotlin/ru/ysu/lms/academic/lab/LabDetailsIntegrationTest.kt b/services/academic-service/src/test/kotlin/ru/ysu/lms/academic/lab/LabDetailsIntegrationTest.kt new file mode 100644 index 0000000..c72e79f --- /dev/null +++ b/services/academic-service/src/test/kotlin/ru/ysu/lms/academic/lab/LabDetailsIntegrationTest.kt @@ -0,0 +1,268 @@ +package ru.ysu.lms.academic.lab + +import org.junit.jupiter.api.Test +import org.mockito.ArgumentMatchers.anyString +import org.mockito.Mockito.`when` +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.boot.testcontainers.service.connection.ServiceConnection +import org.springframework.security.core.authority.SimpleGrantedAuthority +import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt +import org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity +import org.springframework.test.context.bean.override.mockito.MockitoBean +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status +import org.springframework.test.web.servlet.setup.DefaultMockMvcBuilder +import org.springframework.test.web.servlet.setup.MockMvcBuilders +import org.springframework.transaction.annotation.Transactional +import org.springframework.web.context.WebApplicationContext +import org.testcontainers.junit.jupiter.Container +import org.testcontainers.junit.jupiter.Testcontainers +import org.testcontainers.postgresql.PostgreSQLContainer +import ru.ysu.lms.academic.course.CourseCreateRequest +import ru.ysu.lms.academic.course.CourseGroupRequest +import ru.ysu.lms.academic.course.CourseService +import ru.ysu.lms.academic.course.CourseTeacherRequest +import ru.ysu.lms.academic.identity.IdentityClient +import java.util.UUID + +/** + * Пакетные детали лабораторных курса: один запрос отдаёт все лабораторные с их + * уровнями и вариантами, без утечки вариантов между уровнями и лабораторными. + * Доступ — тот же, что у `GET /api/v1/labs?courseId=`: `requireCourseAccess`. + */ +@Testcontainers +@SpringBootTest +@Transactional +class LabDetailsIntegrationTest { + companion object { + @Container + @ServiceConnection + @JvmStatic + val postgres = PostgreSQLContainer("postgres:18") + } + + @Autowired + private lateinit var context: WebApplicationContext + + @Autowired + private lateinit var courseService: CourseService + + @Autowired + private lateinit var labService: LabService + + @MockitoBean + private lateinit var identity: IdentityClient + + private val mockMvc: MockMvc by lazy { + MockMvcBuilders + .webAppContextSetup(context) + .apply(springSecurity()) + .build() + } + + @Test + fun `teacher gets all course labs with levels and variants in one call`() { + val courseId = newCourse() + val teacherId = UUID.randomUUID() + assignTeacher(courseId, teacherId) + seedFirstLab(courseId) + seedSecondLab(courseId) + + mockMvc + .perform( + get("/api/v1/labs/details") + .param("courseId", courseId.toString()) + .with(jwtFor(teacherId, "TEACHER")), + ).andExpect(status().isOk) + .andExpect(jsonPath("$.length()").value(2)) + .andExpect(jsonPath("$[0].number").value(1)) + .andExpect(jsonPath("$[0].title").value("Первая лабораторная")) + .andExpect(jsonPath("$[0].courseId").value(courseId.toString())) + .andExpect(jsonPath("$[0].levels.length()").value(2)) + .andExpect(jsonPath("$[0].levels[0].code").value("basic")) + .andExpect(jsonPath("$[0].levels[0].title").value("Базовый уровень")) + .andExpect(jsonPath("$[0].levels[0].variants.length()").value(2)) + .andExpect(jsonPath("$[0].levels[0].variants[0].number").value("2")) + .andExpect(jsonPath("$[0].levels[0].variants[0].title").value("Б2")) + .andExpect(jsonPath("$[0].levels[0].variants[1].number").value("10")) + .andExpect(jsonPath("$[0].levels[0].variants[1].title").value("Б10")) + .andExpect(jsonPath("$[0].levels[1].code").value("advanced")) + .andExpect(jsonPath("$[0].levels[1].title").value("Продвинутый уровень")) + .andExpect(jsonPath("$[0].levels[1].variants.length()").value(1)) + .andExpect(jsonPath("$[0].levels[1].variants[0].number").value("1")) + .andExpect(jsonPath("$[0].levels[1].variants[0].title").value("А1")) + .andExpect(jsonPath("$[1].number").value(2)) + .andExpect(jsonPath("$[1].title").value("Вторая лабораторная")) + .andExpect(jsonPath("$[1].levels.length()").value(1)) + .andExpect(jsonPath("$[1].levels[0].code").value("basic")) + .andExpect(jsonPath("$[1].levels[0].variants.length()").value(1)) + .andExpect(jsonPath("$[1].levels[0].variants[0].number").value("3")) + .andExpect(jsonPath("$[1].levels[0].variants[0].title").value("В3")) + } + + @Test + fun `admin gets all labs of any course in one call`() { + val courseId = newCourse() + seedFirstLab(courseId) + + mockMvc + .perform( + get("/api/v1/labs/details") + .param("courseId", courseId.toString()) + .with(jwtFor(UUID.randomUUID(), "ADMIN")), + ).andExpect(status().isOk) + .andExpect(jsonPath("$.length()").value(1)) + .andExpect(jsonPath("$[0].number").value(1)) + .andExpect(jsonPath("$[0].levels.length()").value(2)) + } + + @Test + fun `foreign teacher is forbidden for lab details`() { + val courseId = newCourse() + seedFirstLab(courseId) + + mockMvc + .perform( + get("/api/v1/labs/details") + .param("courseId", courseId.toString()) + .with(jwtFor(UUID.randomUUID(), "TEACHER")), + ).andExpect(status().isForbidden) + } + + @Test + fun `student of the course receives lab details`() { + val courseId = newCourse() + seedFirstLab(courseId) + val groupId = UUID.randomUUID() + courseService.attachGroup(courseId, CourseGroupRequest(groupId.toString())) + `when`(identity.currentGroupIds(anyString())).thenReturn(listOf(groupId)) + + mockMvc + .perform( + get("/api/v1/labs/details") + .param("courseId", courseId.toString()) + .with(jwtFor(UUID.randomUUID(), "STUDENT")), + ).andExpect(status().isOk) + .andExpect(jsonPath("$.length()").value(1)) + .andExpect(jsonPath("$[0].levels.length()").value(2)) + } + + @Test + fun `student outside the course is forbidden for lab details`() { + val courseId = newCourse() + seedFirstLab(courseId) + `when`(identity.currentGroupIds(anyString())).thenReturn(listOf(UUID.randomUUID())) + + mockMvc + .perform( + get("/api/v1/labs/details") + .param("courseId", courseId.toString()) + .with(jwtFor(UUID.randomUUID(), "STUDENT")), + ).andExpect(status().isForbidden) + } + + @Test + fun `course without labs returns an empty details list`() { + val courseId = newCourse() + val teacherId = UUID.randomUUID() + assignTeacher(courseId, teacherId) + + mockMvc + .perform( + get("/api/v1/labs/details") + .param("courseId", courseId.toString()) + .with(jwtFor(teacherId, "TEACHER")), + ).andExpect(status().isOk) + .andExpect(jsonPath("$.length()").value(0)) + } + + private fun newCourse(): UUID = + UUID.fromString( + courseService + .create(CourseCreateRequest("Курс ${UUID.randomUUID()}"), UUID.randomUUID()) + .id, + ) + + private fun assignTeacher( + courseId: UUID, + teacherId: UUID, + ) { + courseService.assignTeacher(courseId, CourseTeacherRequest(teacherId.toString())) + } + + private fun seedFirstLab(courseId: UUID) { + createLab( + courseId = courseId, + number = 1, + title = "Первая лабораторная", + levels = + listOf( + LevelSaveRequest(code = "basic", title = "Базовый уровень"), + LevelSaveRequest(code = "advanced", title = "Продвинутый уровень"), + ), + variants = + listOf( + SeededVariant("basic", "10", "Б10"), + SeededVariant("basic", "2", "Б2"), + SeededVariant("advanced", "1", "А1"), + ), + ) + } + + private fun seedSecondLab(courseId: UUID) { + createLab( + courseId = courseId, + number = 2, + title = "Вторая лабораторная", + levels = listOf(LevelSaveRequest(code = "basic", title = "Базовый уровень")), + variants = listOf(SeededVariant("basic", "3", "В3")), + ) + } + + private fun createLab( + courseId: UUID, + number: Int, + title: String, + levels: List, + variants: List, + ) { + val created = + labService.create( + LabCreateRequest( + number = number, + title = title, + courseId = courseId, + levels = levels, + ), + ) + val levelIdsByCode = created.levels.associate { it.code to it.id } + val labId = UUID.fromString(created.id) + variants.forEach { variant -> + labService.addVariant( + labId, + VariantSaveRequest( + labLevelId = levelIdsByCode.getValue(variant.levelCode), + number = variant.number, + title = variant.title, + statementMd = "Условие ${variant.title}", + ), + ) + } + } + + private fun jwtFor( + subject: UUID, + role: String, + ) = jwt() + .authorities(SimpleGrantedAuthority("ROLE_$role")) + .jwt { it.subject(subject.toString()).claim("role", role) } + + private data class SeededVariant( + val levelCode: String, + val number: String, + val title: String, + ) +} diff --git a/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/audit/AuditController.kt b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/audit/AuditController.kt index fe2b610..86ceb04 100644 --- a/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/audit/AuditController.kt +++ b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/audit/AuditController.kt @@ -18,5 +18,6 @@ class AuditController( @RequestParam(defaultValue = "100") limit: Int, @RequestParam(required = false) action: String?, @RequestParam(defaultValue = "0") page: Int, - ): List = auditService.list(limit, action, page) + @RequestParam(required = false) cursor: String?, + ): List = auditService.list(limit, action, page, cursor) } diff --git a/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/audit/AuditDtos.kt b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/audit/AuditDtos.kt index d7a2d39..2e8df29 100644 --- a/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/audit/AuditDtos.kt +++ b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/audit/AuditDtos.kt @@ -24,4 +24,6 @@ data class AuditEventDto( val courseId: String?, val studentId: String?, val details: String, + /** Непрозрачный курсор для следующей keyset-страницы. */ + val cursor: String? = null, ) diff --git a/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/audit/AuditEventRepository.kt b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/audit/AuditEventRepository.kt index 77c5e02..cbc5799 100644 --- a/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/audit/AuditEventRepository.kt +++ b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/audit/AuditEventRepository.kt @@ -2,6 +2,9 @@ package ru.ysu.lms.identity.audit import org.springframework.data.domain.Pageable import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Query +import org.springframework.data.repository.query.Param +import java.time.Instant import java.util.UUID interface AuditEventRepository : JpaRepository { @@ -13,4 +16,20 @@ interface AuditEventRepository : JpaRepository { action: String, pageable: Pageable, ): List + + /** Keyset-страница: строго после курсорной пары `(occurredAt, id)` при сортировке DESC. */ + @Query( + """ + select e from AuditEvent e + where (:action is null or e.action = :action) + and (e.occurredAt < :beforeAt or (e.occurredAt = :beforeAt and e.id < :beforeId)) + order by e.occurredAt desc, e.id desc + """, + ) + fun findBefore( + @Param("action") action: String?, + @Param("beforeAt") beforeAt: Instant, + @Param("beforeId") beforeId: UUID, + pageable: Pageable, + ): List } diff --git a/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/audit/AuditService.kt b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/audit/AuditService.kt index d2c42a6..bd89295 100644 --- a/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/audit/AuditService.kt +++ b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/audit/AuditService.kt @@ -4,6 +4,8 @@ import org.springframework.data.domain.PageRequest import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional import ru.ysu.lms.common.BadRequestException +import java.time.Instant +import java.util.Base64 import java.util.UUID /** @@ -39,16 +41,22 @@ class AuditService( limit: Int = DEFAULT_LIMIT, action: String? = null, page: Int = 0, + cursor: String? = null, ): List { val size = limit.coerceIn(1, MAX_LIMIT) - val pageIndex = page.coerceAtLeast(0) - val pageable = PageRequest.of(pageIndex, size) val filter = action?.trim()?.takeIf { it.isNotEmpty() } val found = - if (filter == null) { - events.findAllByOrderByOccurredAtDescIdDesc(pageable) + if (cursor != null) { + val (beforeAt, beforeId) = decodeCursor(cursor) + events.findBefore(filter, beforeAt, beforeId, PageRequest.of(0, size)) } else { - events.findAllByActionOrderByOccurredAtDescIdDesc(filter, pageable) + val pageIndex = page.coerceAtLeast(0) + val pageable = PageRequest.of(pageIndex, size) + if (filter == null) { + events.findAllByOrderByOccurredAtDescIdDesc(pageable) + } else { + events.findAllByActionOrderByOccurredAtDescIdDesc(filter, pageable) + } } return found.map(::toDto) } @@ -65,8 +73,30 @@ class AuditService( courseId = event.courseId?.toString(), studentId = event.studentId?.toString(), details = event.details, + cursor = encodeCursor(event.occurredAt, event.id), ) + /** Курсор — URL-safe Base64 без паддинга от пары `occurredAt|id`. */ + private fun encodeCursor( + occurredAt: Instant, + id: UUID, + ): String = + Base64 + .getUrlEncoder() + .withoutPadding() + .encodeToString("$occurredAt|$id".toByteArray(Charsets.UTF_8)) + + private fun decodeCursor(raw: String): Pair { + val parts = + runCatching { String(Base64.getUrlDecoder().decode(raw), Charsets.UTF_8) } + .getOrNull() + ?.split("|", limit = 2) + val occurredAt = parts?.getOrNull(0)?.let { runCatching { Instant.parse(it) }.getOrNull() } + val id = parts?.getOrNull(1)?.let { runCatching { UUID.fromString(it) }.getOrNull() } + if (occurredAt == null || id == null) throw BadRequestException(INVALID_CURSOR) + return occurredAt to id + } + private fun requireText( raw: String, field: String, @@ -97,5 +127,6 @@ class AuditService( const val ENTITY_ID_MAX = 128 const val ACTOR_LOGIN_MAX = 100 const val DETAILS_MAX = 2000 + const val INVALID_CURSOR = "Некорректный курсор аудита" } } diff --git a/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/auth/refresh/RefreshToken.kt b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/auth/refresh/RefreshToken.kt index 0364262..94f1646 100644 --- a/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/auth/refresh/RefreshToken.kt +++ b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/auth/refresh/RefreshToken.kt @@ -26,6 +26,8 @@ class RefreshToken( var issuedAt: Instant = Instant.now(), @Column(name = "expires_at", nullable = false) var expiresAt: Instant = Instant.now(), + @Column(name = "absolute_expires_at", nullable = false) + var absoluteExpiresAt: Instant = Instant.now(), @Column(name = "revoked_at") var revokedAt: Instant? = null, @Column(name = "replaced_by") diff --git a/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/auth/refresh/RefreshTokenRepository.kt b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/auth/refresh/RefreshTokenRepository.kt index 753c8ab..d173da7 100644 --- a/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/auth/refresh/RefreshTokenRepository.kt +++ b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/auth/refresh/RefreshTokenRepository.kt @@ -1,10 +1,21 @@ package ru.ysu.lms.identity.auth.refresh import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Modifying +import org.springframework.data.jpa.repository.Query +import org.springframework.data.repository.query.Param +import java.time.Instant import java.util.UUID interface RefreshTokenRepository : JpaRepository { fun findByTokenHash(tokenHash: String): RefreshToken? fun findAllByAccountIdAndRevokedAtIsNull(accountId: UUID): List + + /** Удаляет и sliding-, и absolute-мёртвые токены, чтобы цепочка не жила лишние дни. */ + @Modifying + @Query("delete from RefreshToken t where t.expiresAt < :cutoff or t.absoluteExpiresAt < :cutoff") + fun deleteExpiredBefore( + @Param("cutoff") cutoff: Instant, + ): Int } diff --git a/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/auth/refresh/RefreshTokenService.kt b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/auth/refresh/RefreshTokenService.kt index 7050d59..cd3198b 100644 --- a/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/auth/refresh/RefreshTokenService.kt +++ b/services/identity-service/src/main/kotlin/ru/ysu/lms/identity/auth/refresh/RefreshTokenService.kt @@ -1,8 +1,11 @@ package ru.ysu.lms.identity.auth.refresh +import org.slf4j.LoggerFactory import org.springframework.beans.factory.annotation.Value import org.springframework.context.annotation.Lazy +import org.springframework.scheduling.annotation.Scheduled import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional import ru.ysu.lms.common.Ids import ru.ysu.lms.common.UnauthorizedException import ru.ysu.lms.identity.auth.AuthService @@ -23,6 +26,8 @@ data class RefreshTokenRotation( /** * Выдаёт, ротирует и отзывает refresh-токены. Access-токен живёт минуты, * а сессия продлевается скользяще: каждый refresh возвращает новую пару. + * Абсолютный срок (`absoluteExpiresAt`) при этом не продлевается: сессия + * живёт не дольше [absoluteTtl] с момента выдачи. */ @Service class RefreshTokenService( @@ -30,7 +35,10 @@ class RefreshTokenService( // AuthService сам зависит от RefreshTokenService (выдача при login), поэтому прокси. @Lazy private val authService: AuthService, @Value("\${lms.auth.refresh-token-ttl:7d}") private val ttl: Duration, + @Value("\${lms.auth.absolute-session-ttl:30d}") private val absoluteTtl: Duration, + @Value("\${lms.auth.refresh-token-retention:30d}") private val retention: Duration, ) { + private val log = LoggerFactory.getLogger(RefreshTokenService::class.java) private val random = SecureRandom() fun issue(accountId: UUID): String { @@ -48,13 +56,16 @@ class RefreshTokenService( fun rotate(raw: String): RefreshTokenRotation { val token = activeTokenOrThrow(raw) val now = Instant.now() + if (!token.absoluteExpiresAt.isAfter(now)) { + throw UnauthorizedException(SESSION_EXPIRED) + } authService.ensureSessionAllowed(token.accountId) val newId = Ids.v7() val newRaw = generateRaw() token.revokedAt = now token.replacedBy = newId - tokens.saveAll(listOf(token, newToken(token.accountId, newRaw, now, newId))) + tokens.saveAll(listOf(token, newToken(token.accountId, newRaw, now, newId, token.absoluteExpiresAt))) return RefreshTokenRotation(token.accountId, newRaw) } @@ -95,11 +106,32 @@ class RefreshTokenService( tokens.saveAll(active) } + /** + * Удаляет токены, истёкшие раньше `now - retention`. Отозванные, но ещё + * не истёкшие токены сохраняются: по ним детектируется reuse. + */ + @Transactional + fun cleanupExpired(now: Instant = Instant.now()): Int { + val cutoff = now.minus(retention) + val deleted = tokens.deleteExpiredBefore(cutoff) + if (deleted > 0) { + log.info("Удалено истёкших refresh-токенов: {}", deleted) + } + return deleted + } + + @Scheduled(cron = "\${lms.auth.refresh-token-cleanup-cron:0 30 3 * * *}") + @Transactional + fun cleanupExpiredTokens() { + cleanupExpired() + } + private fun newToken( accountId: UUID, raw: String, now: Instant, id: UUID = Ids.v7(), + absoluteExpiresAt: Instant = now.plus(absoluteTtl), ): RefreshToken = RefreshToken( id = id, @@ -107,6 +139,7 @@ class RefreshTokenService( tokenHash = hash(raw), issuedAt = now, expiresAt = now.plus(ttl), + absoluteExpiresAt = absoluteExpiresAt, ) private fun generateRaw(): String { diff --git a/services/identity-service/src/main/resources/application.yml b/services/identity-service/src/main/resources/application.yml index b9b3512..563801e 100644 --- a/services/identity-service/src/main/resources/application.yml +++ b/services/identity-service/src/main/resources/application.yml @@ -45,3 +45,8 @@ lms: auth: # Скользящая сессия: каждый refresh продлевает её на этот срок. refresh-token-ttl: ${LMS_REFRESH_TOKEN_TTL:7d} + # Абсолютный потолок сессии: ротация не может продлить её дальше этого срока. + absolute-session-ttl: ${LMS_SESSION_ABSOLUTE_TIMEOUT:30d} + # Сколько хранить истёкшие refresh-токены перед очисткой. + refresh-token-retention: ${LMS_REFRESH_TOKEN_RETENTION:30d} + refresh-token-cleanup-cron: ${LMS_REFRESH_TOKEN_CLEANUP_CRON:0 30 3 * * *} diff --git a/services/identity-service/src/main/resources/db/migration/V12__refresh_absolute.sql b/services/identity-service/src/main/resources/db/migration/V12__refresh_absolute.sql new file mode 100644 index 0000000..128a56d --- /dev/null +++ b/services/identity-service/src/main/resources/db/migration/V12__refresh_absolute.sql @@ -0,0 +1,7 @@ +-- Абсолютный срок жизни сессии: ротация refresh-токена продлевает скользящее +-- окно, но не может растянуть сессию дальше absolute_expires_at. +-- Бэкфилл существующих строк использует дефолтные 30 дней; если на стенде +-- LMS_SESSION_ABSOLUTE_TIMEOUT меньше, старые сессии доживают один раз до 30 дней. +ALTER TABLE identity.refresh_tokens ADD COLUMN absolute_expires_at timestamptz; +UPDATE identity.refresh_tokens SET absolute_expires_at = issued_at + interval '30 days' WHERE absolute_expires_at IS NULL; +ALTER TABLE identity.refresh_tokens ALTER COLUMN absolute_expires_at SET NOT NULL; diff --git a/services/identity-service/src/main/resources/db/migration/V13__audit_keyset_indexes.sql b/services/identity-service/src/main/resources/db/migration/V13__audit_keyset_indexes.sql new file mode 100644 index 0000000..9c2dfe2 --- /dev/null +++ b/services/identity-service/src/main/resources/db/migration/V13__audit_keyset_indexes.sql @@ -0,0 +1,4 @@ +-- Индексы под keyset-пагинацию: сортировка (occurred_at DESC, id DESC) и та же +-- сортировка с фильтром по action. +CREATE INDEX audit_events_occurred_id_idx ON identity.audit_events (occurred_at DESC, id DESC); +CREATE INDEX audit_events_action_occurred_id_idx ON identity.audit_events (action, occurred_at DESC, id DESC); diff --git a/services/identity-service/src/test/kotlin/ru/ysu/lms/identity/RefreshTokenIntegrationTest.kt b/services/identity-service/src/test/kotlin/ru/ysu/lms/identity/RefreshTokenIntegrationTest.kt index 2311c88..ca42123 100644 --- a/services/identity-service/src/test/kotlin/ru/ysu/lms/identity/RefreshTokenIntegrationTest.kt +++ b/services/identity-service/src/test/kotlin/ru/ysu/lms/identity/RefreshTokenIntegrationTest.kt @@ -25,6 +25,8 @@ import ru.ysu.lms.identity.auth.AuthService import ru.ysu.lms.identity.auth.ChangePasswordRequest import ru.ysu.lms.identity.auth.LoginRequest import ru.ysu.lms.identity.auth.RefreshTokenRequest +import ru.ysu.lms.identity.auth.refresh.RefreshToken +import ru.ysu.lms.identity.auth.refresh.RefreshTokenRepository import ru.ysu.lms.identity.auth.refresh.RefreshTokenService import ru.ysu.lms.identity.roster.CreateGroupRequest import ru.ysu.lms.identity.roster.CreateStudentRequest @@ -32,6 +34,7 @@ import ru.ysu.lms.identity.roster.Credential import ru.ysu.lms.identity.roster.GroupService import ru.ysu.lms.identity.roster.StudentLifecycleService import java.time.Duration +import java.time.Instant import java.util.UUID import java.util.concurrent.atomic.AtomicInteger @@ -62,6 +65,8 @@ class RefreshTokenIntegrationTest { @Autowired lateinit var refreshTokens: RefreshTokenService + @Autowired lateinit var refreshTokenRepository: RefreshTokenRepository + @Autowired lateinit var groupService: GroupService @Autowired lateinit var lifecycle: StudentLifecycleService @@ -188,6 +193,93 @@ class RefreshTokenIntegrationTest { .hasMessage(RefreshTokenService.SESSION_EXPIRED) } + @Test + fun `refresh past the absolute session expiry is rejected with unauthorized`() { + val student = createStudent() + val refreshToken = login(student).refreshToken!! + val token = activeToken(UUID.fromString(student.id)) + token.absoluteExpiresAt = Instant.now().minusSeconds(1) + refreshTokenRepository.save(token) + + mockMvc + .post("/api/v1/auth/refresh") { + contentType = MediaType.APPLICATION_JSON + content = jsonMapper.writeValueAsString(RefreshTokenRequest(refreshToken)) + }.andExpect { + status { isUnauthorized() } + } + + assertThatThrownBy { refreshTokens.rotate(refreshToken) } + .isInstanceOf(UnauthorizedException::class.java) + .hasMessage(RefreshTokenService.SESSION_EXPIRED) + } + + @Test + fun `rotation keeps the absolute session expiry unchanged`() { + val student = createStudent() + val accountId = UUID.fromString(student.id) + val original = login(student).refreshToken!! + val absoluteExpiresAt = activeToken(accountId).absoluteExpiresAt + assertThat(absoluteExpiresAt).isAfter(Instant.now().plus(Duration.ofDays(29))) + + val rotated = refreshTokens.rotate(original) + assertThat(activeToken(accountId).absoluteExpiresAt).isEqualTo(absoluteExpiresAt) + + val rotatedAgain = refreshTokens.rotate(rotated.refreshToken) + assertThat(rotatedAgain.accountId).isEqualTo(accountId) + assertThat(activeToken(accountId).absoluteExpiresAt).isEqualTo(absoluteExpiresAt) + } + + @Test + fun `cleanup deletes only tokens expired beyond retention`() { + val student = createStudent() + val accountId = UUID.fromString(student.id) + val now = Instant.parse("2026-01-31T00:00:00Z") + + val expired = + token( + accountId, + "a".repeat(64), + now.minus(Duration.ofDays(40)), + now.minus(Duration.ofDays(31)), + now.minus(Duration.ofDays(10)), + ) + val revokedRecently = + token( + accountId, + "b".repeat(64), + now.minus(Duration.ofDays(2)), + now.minus(Duration.ofDays(1)), + now.plus(Duration.ofDays(28)), + ) + revokedRecently.revokedAt = now.minus(Duration.ofHours(2)) + val absoluteDead = + token( + accountId, + "d".repeat(64), + now.minus(Duration.ofDays(45)), + now.plus(Duration.ofDays(7)), + now.minus(Duration.ofDays(40)), + ) + val fresh = + token( + accountId, + "c".repeat(64), + now, + now.plus(Duration.ofDays(7)), + now.plus(Duration.ofDays(30)), + ) + refreshTokenRepository.saveAll(listOf(expired, revokedRecently, absoluteDead, fresh)) + + val deleted = refreshTokens.cleanupExpired(now) + + assertThat(deleted).isEqualTo(2) + assertThat(refreshTokenRepository.findById(expired.id)).isEmpty + assertThat(refreshTokenRepository.findById(absoluteDead.id)).isEmpty + assertThat(refreshTokenRepository.findById(revokedRecently.id)).isPresent + assertThat(refreshTokenRepository.findById(fresh.id)).isPresent + } + @Test fun `logout revokes the refresh token idempotently`() { val student = createStudent() @@ -252,6 +344,23 @@ class RefreshTokenIntegrationTest { .hasMessage("Группа закрыта") } + private fun activeToken(accountId: UUID): RefreshToken = refreshTokenRepository.findAllByAccountIdAndRevokedAtIsNull(accountId).single() + + private fun token( + accountId: UUID, + tokenHash: String, + issuedAt: Instant, + expiresAt: Instant, + absoluteExpiresAt: Instant, + ): RefreshToken = + RefreshToken( + accountId = accountId, + tokenHash = tokenHash, + issuedAt = issuedAt, + expiresAt = expiresAt, + absoluteExpiresAt = absoluteExpiresAt, + ) + private fun createStudent(): StudentFixture { val number = sequence.incrementAndGet() val group = diff --git a/services/identity-service/src/test/kotlin/ru/ysu/lms/identity/audit/AuditIntegrationTest.kt b/services/identity-service/src/test/kotlin/ru/ysu/lms/identity/audit/AuditIntegrationTest.kt index 5beabfa..ba06ea0 100644 --- a/services/identity-service/src/test/kotlin/ru/ysu/lms/identity/audit/AuditIntegrationTest.kt +++ b/services/identity-service/src/test/kotlin/ru/ysu/lms/identity/audit/AuditIntegrationTest.kt @@ -216,6 +216,65 @@ class AuditIntegrationTest { assertTrue((first + second).intersect(third.toSet()).isEmpty()) } + @Test + fun `cursor pagination walks all events newest first`() { + repeat(5) { index -> + write(AuditEventRequest(action = "AUDIT_CURSOR", entityType = "STUDENT", details = "cursor-${index + 1}")) + } + + val first = auditPage(limit = 2, page = 0) + assertEquals(listOf("cursor-5", "cursor-4"), first.details) + assertTrue(first.cursor != null) + + val second = auditPage(limit = 2, cursor = first.cursor) + assertEquals(listOf("cursor-3", "cursor-2"), second.details) + assertTrue(first.details.intersect(second.details.toSet()).isEmpty()) + + val third = auditPage(limit = 2, cursor = second.cursor) + assertEquals(listOf("cursor-1"), third.details) + assertTrue((first.details + second.details).intersect(third.details.toSet()).isEmpty()) + + val all = first.details + second.details + third.details + assertEquals(5, all.distinct().size) + } + + @Test + fun `cursor pagination respects the action filter`() { + write(AuditEventRequest(action = "AUDIT_CURSOR_FILTER", entityType = "STUDENT", details = "a1")) + write(AuditEventRequest(action = "AUDIT_CURSOR_OTHER", entityType = "STUDENT", details = "x")) + write(AuditEventRequest(action = "AUDIT_CURSOR_FILTER", entityType = "STUDENT", details = "a2")) + write(AuditEventRequest(action = "AUDIT_CURSOR_FILTER", entityType = "STUDENT", details = "a3")) + + val first = auditPage(limit = 2, action = "AUDIT_CURSOR_FILTER") + assertEquals(listOf("a3", "a2"), first.details) + + val second = auditPage(limit = 2, action = "AUDIT_CURSOR_FILTER", cursor = first.cursor) + assertEquals(listOf("a1"), second.details) + } + + @Test + fun `malformed audit cursor is rejected with bad request`() { + mockMvc + .get("/api/v1/audit") { + with(admin()) + param("cursor", "%%%not-base64%%%") + }.andExpect { + status { isBadRequest() } + jsonPath("$.detail") { value("Некорректный курсор аудита") } + } + } + + @Test + fun `cursor from the newest event returns an empty page`() { + write(AuditEventRequest(action = "AUDIT_CURSOR_END", entityType = "STUDENT", details = "only")) + + val first = auditPage(limit = 1, action = "AUDIT_CURSOR_END") + assertEquals(listOf("only"), first.details) + + val next = auditPage(limit = 5, action = "AUDIT_CURSOR_END", cursor = first.cursor) + assertEquals(emptyList(), next.details) + } + @Test fun `negative page is clamped to the first page and page past the end is empty`() { repeat(3) { index -> @@ -319,6 +378,42 @@ class AuditIntegrationTest { assertTrue(auditEvents.findById(deleteEvent.id).isPresent) } + private data class AuditPage( + val details: List, + val cursor: String?, + ) + + private fun auditPage( + limit: Int, + page: Int? = null, + cursor: String? = null, + action: String? = null, + ): AuditPage { + val body = + mockMvc + .get("/api/v1/audit") { + with(admin()) + param("limit", limit.toString()) + page?.let { param("page", it.toString()) } + cursor?.let { param("cursor", it) } + action?.let { param("action", it) } + }.andExpect { + status { isOk() } + }.andReturn() + .response + .getContentAsString(StandardCharsets.UTF_8) + val nodes = objectMapper.readTree(body) + return AuditPage( + details = nodes.map { it.path("details").asText() }, + cursor = + nodes + .lastOrNull() + ?.path("cursor") + ?.asText() + ?.takeIf { it.isNotEmpty() }, + ) + } + private fun detailsOfPage( limit: Int, page: Int, diff --git a/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/AcademicClient.kt b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/AcademicClient.kt index dc252e5..f3d8fce 100644 --- a/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/AcademicClient.kt +++ b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/AcademicClient.kt @@ -80,37 +80,27 @@ class AcademicClient( } /** - * Лабораторные курса вместе с уровнями и вариантами: список отдаёт только сводки, - * поэтому детали каждой лабораторной подтягиваются отдельно. Ошибка детали не - * должна ронять весь профиль — остаётся сводка без уровней. + * Лабораторные курса вместе с уровнями и вариантами: один пакетный запрос + * деталей на весь курс, без N+1 запросов на каждую лабораторную. */ fun getCourseLabs( courseId: String, token: String, ): List { val type = object : ParameterizedTypeReference>() {} - val summaries = + val labs = try { client .get() - .uri("/api/v1/labs?courseId={courseId}", courseId) + .uri("/api/v1/labs/details?courseId={courseId}", courseId) .header("Authorization", "Bearer $token") .retrieve() .body(type) ?: emptyList() } catch (ex: RestClientResponseException) { throw courseError(ex) } - return summaries.map { summary -> - if (summary.id.isBlank()) { - summary - } else { - val detail = runCatching { getLab(summary.id, token) }.getOrNull() ?: summary - detail.copy( - courseId = detail.courseId ?: summary.courseId ?: courseId, - courseNo = detail.courseNo ?: summary.courseNo, - courseTitle = detail.courseTitle ?: summary.courseTitle, - ) - } + return labs.map { lab -> + if (lab.courseId.isNullOrBlank()) lab.copy(courseId = courseId) else lab } } } diff --git a/services/progress-service/src/test/kotlin/ru/ysu/lms/progress/AcademicClientBatchLabsTest.kt b/services/progress-service/src/test/kotlin/ru/ysu/lms/progress/AcademicClientBatchLabsTest.kt new file mode 100644 index 0000000..c8f9ef9 --- /dev/null +++ b/services/progress-service/src/test/kotlin/ru/ysu/lms/progress/AcademicClientBatchLabsTest.kt @@ -0,0 +1,173 @@ +package ru.ysu.lms.progress + +import com.sun.net.httpserver.HttpServer +import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.assertThatThrownBy +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import ru.ysu.lms.common.ForbiddenException +import java.net.InetSocketAddress +import java.util.concurrent.CopyOnWriteArrayList + +/** Пакетная загрузка лабораторных курса: один запрос вместо N+1 по каждой лабораторной. */ +class AcademicClientBatchLabsTest { + private lateinit var server: HttpServer + private val requestedUris = CopyOnWriteArrayList() + + @Volatile + private var responseStatus = 200 + + @Volatile + private var responseBody = "[]" + + @BeforeEach + fun startServer() { + server = HttpServer.create(InetSocketAddress("127.0.0.1", 0), 0) + server.createContext(DETAILS_PATH) { exchange -> + requestedUris += exchange.requestURI.toString() + val bytes = responseBody.toByteArray(Charsets.UTF_8) + exchange.responseHeaders.add("Content-Type", "application/json") + exchange.sendResponseHeaders(responseStatus, bytes.size.toLong()) + exchange.responseBody.use { it.write(bytes) } + } + server.start() + } + + @AfterEach + fun stopServer() { + server.stop(0) + } + + @Test + fun `getCourseLabs loads all labs with levels and variants in one request`() { + respond(200, LABS_JSON) + + val labs = client().getCourseLabs(COURSE_ID, TOKEN) + + assertThat(requestedUris).hasSize(1) + assertThat(requestedUris.single()) + .startsWith(DETAILS_PATH) + .contains("courseId=$COURSE_ID") + assertThat(labs).hasSize(2) + + val first = labs[0] + assertThat(first.id).isEqualTo(LAB_ONE_ID) + assertThat(first.number).isEqualTo(1) + assertThat(first.title).isEqualTo("Лабораторная 1") + assertThat(first.courseId).isEqualTo(COURSE_ID) + assertThat(first.levels).hasSize(1) + + val firstLevel = first.levels.single() + assertThat(firstLevel.code).isEqualTo("L1") + assertThat(firstLevel.variants).hasSize(2) + assertThat(firstLevel.variants.map { it.number }).containsExactly("1", "2") + assertThat(firstLevel.variants[1].statementMd).isEqualTo("Условие 1.2") + + val second = labs[1] + assertThat(second.id).isEqualTo(LAB_TWO_ID) + assertThat(second.levels).hasSize(1) + + val secondLevel = second.levels.single() + assertThat(secondLevel.variants.single().id).isEqualTo(VARIANT_TWO_ID) + } + + @Test + fun `getCourseLabs falls back to requested course id when response omits it`() { + respond(200, LABS_WITHOUT_COURSE_JSON) + + val labs = client().getCourseLabs(COURSE_ID, TOKEN) + + assertThat(requestedUris).hasSize(1) + assertThat(labs).hasSize(2) + assertThat(labs).allSatisfy { lab -> assertThat(lab.courseId).isEqualTo(COURSE_ID) } + } + + @Test + fun `getCourseLabs maps forbidden response to ForbiddenException`() { + respond(403, "{}") + + assertThatThrownBy { client().getCourseLabs(COURSE_ID, TOKEN) } + .isInstanceOf(ForbiddenException::class.java) + .hasMessage("Курс недоступен") + assertThat(requestedUris).hasSize(1) + } + + private fun respond( + status: Int, + body: String, + ) { + responseStatus = status + responseBody = body + } + + private fun client(): AcademicClient = AcademicClient(baseUrl = "http://127.0.0.1:${server.address.port}") + + private companion object { + const val DETAILS_PATH = "/api/v1/labs/details" + const val TOKEN = "test-token" + const val COURSE_ID = "44444444-4444-4444-4444-444444444444" + const val LAB_ONE_ID = "11111111-1111-1111-1111-111111111111" + const val LAB_TWO_ID = "22222222-2222-2222-2222-222222222222" + const val VARIANT_TWO_ID = "33333333-3333-3333-3333-333333333333" + + val LABS_JSON = + """ + [ + { + "id": "$LAB_ONE_ID", + "number": 1, + "title": "Лабораторная 1", + "status": "PUBLISHED", + "descriptionMd": "Описание 1", + "deadlineAt": "2026-11-01T00:00:00Z", + "defenseAt": "2026-11-08T00:00:00Z", + "pointsMax": 10, + "courseId": "$COURSE_ID", + "courseNo": 1, + "courseTitle": "Курс", + "levels": [ + { + "id": "level-1", + "code": "L1", + "title": "Уровень 1", + "variants": [ + { "id": "variant-1", "number": "1", "title": "Вариант 1.1", "statementMd": "Условие 1.1" }, + { "id": "variant-2", "number": "2", "title": "Вариант 1.2", "statementMd": "Условие 1.2" } + ] + } + ] + }, + { + "id": "$LAB_TWO_ID", + "number": 2, + "title": "Лабораторная 2", + "status": "PUBLISHED", + "descriptionMd": "Описание 2", + "pointsMax": 5, + "courseId": "$COURSE_ID", + "courseNo": 1, + "courseTitle": "Курс", + "levels": [ + { + "id": "level-2", + "code": "L2", + "title": "Уровень 2", + "variants": [ + { "id": "$VARIANT_TWO_ID", "number": "1", "title": "Вариант 2.1", "statementMd": "Условие 2.1" } + ] + } + ] + } + ] + """.trimIndent() + + val LABS_WITHOUT_COURSE_JSON = + """ + [ + { "id": "$LAB_ONE_ID", "number": 1, "title": "Лабораторная 1", "levels": [] }, + { "id": "$LAB_TWO_ID", "number": 2, "title": "Лабораторная 2", "levels": [] } + ] + """.trimIndent() + } +} diff --git a/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminAuditController.kt b/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminAuditController.kt index c5eb69b..a399799 100644 --- a/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminAuditController.kt +++ b/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminAuditController.kt @@ -20,30 +20,49 @@ class AdminAuditController( markdown: MarkdownRenderer, mapper: ObjectMapper, ) : AdminSupport(api, markdown, mapper) { + @Suppress("LongParameterList") @GetMapping fun list( @RequestParam(required = false) action: String?, @RequestParam(required = false) limit: String?, @RequestParam(required = false) page: String?, + @RequestParam(required = false) cursor: String?, session: HttpSession, model: Model, ): String { val filter = action?.trim()?.takeIf { it.isNotEmpty() } val pageSize = (limit?.trim()?.toIntOrNull() ?: DEFAULT_LIMIT).coerceIn(1, MAX_LIMIT) val pageNumber = (page?.trim()?.toIntOrNull() ?: 0).coerceAtLeast(0) + val cursorValue = cursor?.trim()?.takeIf { it.isNotEmpty() } val events: List = try { - api.listAudit(token(session), pageSize, filter, pageNumber) + api.listAudit( + token = token(session), + limit = pageSize, + action = filter, + page = pageNumber, + cursor = cursorValue, + ) } catch (ex: ApiException) { model.addAttribute("error", ex.message) emptyList() } + val nextCursor = + if (events.size == pageSize) { + events.lastOrNull()?.cursor?.takeIf { it.isNotBlank() } + } else { + null + } + val cursorMode = cursorValue != null model.addAttribute("events", AuditViews.rows(events)) model.addAttribute("action", filter.orEmpty()) model.addAttribute("limit", pageSize) model.addAttribute("page", pageNumber) - model.addAttribute("hasNext", events.size == pageSize) - model.addAttribute("hasPrev", pageNumber > 0) + model.addAttribute("nextCursor", nextCursor) + model.addAttribute("hasNext", nextCursor != null) + // «Назад» работает только для offset-совместимости; основной путь — cursor + «В начало». + model.addAttribute("hasPrev", !cursorMode && pageNumber > 0) + model.addAttribute("isCursor", cursorMode) model.addAttribute("actionOptions", AuditViews.actionOptions(filter)) return "admin/audit" } diff --git a/web/src/main/kotlin/ru/ysu/lms/web/admin/LegacyAssetsController.kt b/web/src/main/kotlin/ru/ysu/lms/web/admin/LegacyAssetsController.kt new file mode 100644 index 0000000..8d268d4 --- /dev/null +++ b/web/src/main/kotlin/ru/ysu/lms/web/admin/LegacyAssetsController.kt @@ -0,0 +1,38 @@ +package ru.ysu.lms.web.admin + +import jakarta.servlet.http.HttpSession +import org.springframework.stereotype.Controller +import org.springframework.ui.Model +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RequestMapping +import ru.ysu.lms.web.api.ApiClient +import ru.ysu.lms.web.api.ApiException +import ru.ysu.lms.web.api.legacyAssets +import ru.ysu.lms.web.session.SessionKeys + +/** Инвентаризация медиафайлов, на которые больше не ссылается ни одна статья. */ +@Controller +@RequestMapping("/admin/legacy-assets") +class LegacyAssetsController( + private val api: ApiClient, +) { + @GetMapping + fun list( + session: HttpSession, + model: Model, + ): String { + val assets = + try { + api.legacyAssets(token(session)).sorted() + } catch (ex: ApiException) { + model.addAttribute("error", ex.message) + emptyList() + } + model.addAttribute("assets", assets) + return "admin/legacy-assets" + } + + private fun token(session: HttpSession): String = + session.getAttribute(SessionKeys.TOKEN) as? String + ?: throw ApiException(401, "Сессия истекла, войдите заново") +} diff --git a/web/src/main/kotlin/ru/ysu/lms/web/api/ApiClientAudit.kt b/web/src/main/kotlin/ru/ysu/lms/web/api/ApiClientAudit.kt index 0a44a32..bc7a8c1 100644 --- a/web/src/main/kotlin/ru/ysu/lms/web/api/ApiClientAudit.kt +++ b/web/src/main/kotlin/ru/ysu/lms/web/api/ApiClientAudit.kt @@ -8,6 +8,7 @@ fun ApiClient.listAudit( limit: Int = 100, action: String? = null, page: Int = 0, + cursor: String? = null, ): List { val type = object : ParameterizedTypeReference>() {} return call { @@ -18,6 +19,7 @@ fun ApiClient.listAudit( builder.queryParam("limit", limit) if (!action.isNullOrBlank()) builder.queryParam("action", action) builder.queryParam("page", page.coerceAtLeast(0)) + if (!cursor.isNullOrBlank()) builder.queryParam("cursor", cursor) builder.build() }.header(HttpHeaders.AUTHORIZATION, bearer(token)) .retrieve() diff --git a/web/src/main/kotlin/ru/ysu/lms/web/api/ApiClientContent.kt b/web/src/main/kotlin/ru/ysu/lms/web/api/ApiClientContent.kt index b22bffd..b8b6c5f 100644 --- a/web/src/main/kotlin/ru/ysu/lms/web/api/ApiClientContent.kt +++ b/web/src/main/kotlin/ru/ysu/lms/web/api/ApiClientContent.kt @@ -131,6 +131,18 @@ fun ApiClient.deleteContentArticle( } } +fun ApiClient.legacyAssets(token: String): List { + val type = object : ParameterizedTypeReference>() {} + return call { + client + .get() + .uri("/api/v1/content/legacy-assets") + .header(HttpHeaders.AUTHORIZATION, bearer(token)) + .retrieve() + .body(type) + } +} + fun ApiClient.contentAsset( name: String, token: String? = null, diff --git a/web/src/main/kotlin/ru/ysu/lms/web/api/ApiDtos.kt b/web/src/main/kotlin/ru/ysu/lms/web/api/ApiDtos.kt index 8bfbff1..0633f4f 100644 --- a/web/src/main/kotlin/ru/ysu/lms/web/api/ApiDtos.kt +++ b/web/src/main/kotlin/ru/ysu/lms/web/api/ApiDtos.kt @@ -900,4 +900,5 @@ data class AuditEventDto( val courseId: String? = null, val studentId: String? = null, val details: String = "", + val cursor: String? = null, ) diff --git a/web/src/main/kotlin/ru/ysu/lms/web/auth/AuthPageController.kt b/web/src/main/kotlin/ru/ysu/lms/web/auth/AuthPageController.kt index 6351af4..e991f7f 100644 --- a/web/src/main/kotlin/ru/ysu/lms/web/auth/AuthPageController.kt +++ b/web/src/main/kotlin/ru/ysu/lms/web/auth/AuthPageController.kt @@ -27,6 +27,7 @@ class AuthPageController( @GetMapping("/login") fun loginPage( + @RequestParam(required = false) expired: String?, session: HttpSession, model: Model, ): String { @@ -37,6 +38,9 @@ class AuthPageController( session.removeAttribute("csrfError") model.addAttribute("error", it) } + if (!expired.isNullOrBlank()) { + model.addAttribute("error", "Срок сессии истёк. Войдите заново.") + } model.addAttribute("adminExists", api.adminExists()) return "login" } @@ -133,6 +137,7 @@ class AuthPageController( val session = request.getSession(true) session.setAttribute(SessionKeys.TOKEN, auth.token) auth.refreshToken?.let { session.setAttribute(SessionKeys.REFRESH_TOKEN, it) } + session.setAttribute(SessionKeys.AUTHENTICATED_AT, System.currentTimeMillis()) session.setAttribute( SessionKeys.TOKEN_EXPIRES_AT, System.currentTimeMillis() + auth.accessTokenExpiresIn * 1000, diff --git a/web/src/main/kotlin/ru/ysu/lms/web/session/AuthInterceptor.kt b/web/src/main/kotlin/ru/ysu/lms/web/session/AuthInterceptor.kt index 528c093..cfbc5d9 100644 --- a/web/src/main/kotlin/ru/ysu/lms/web/session/AuthInterceptor.kt +++ b/web/src/main/kotlin/ru/ysu/lms/web/session/AuthInterceptor.kt @@ -3,11 +3,13 @@ package ru.ysu.lms.web.session import jakarta.servlet.http.HttpServletRequest import jakarta.servlet.http.HttpServletResponse import jakarta.servlet.http.HttpSession +import org.springframework.beans.factory.annotation.Value import org.springframework.stereotype.Component import org.springframework.web.servlet.HandlerInterceptor import ru.ysu.lms.web.api.ApiClient import ru.ysu.lms.web.api.ApiException import ru.ysu.lms.web.api.MeDto +import java.time.Duration /** * Проверяет вход, поддерживает access-токен живым и периодически перепроверяет @@ -19,7 +21,10 @@ import ru.ysu.lms.web.api.MeDto class AuthInterceptor( private val api: ApiClient, private val tokenService: SessionTokenService, + @Value("\${lms.session.absolute-timeout:30d}") absoluteTimeout: Duration = Duration.ofDays(30), ) : HandlerInterceptor { + private val lifetime = SessionLifetime(absoluteTimeout) + override fun preHandle( request: HttpServletRequest, response: HttpServletResponse, @@ -31,10 +36,14 @@ class AuthInterceptor( response.sendRedirect("/login") return false } + if (lifetime.isAbsoluteExpired(session)) { + expire(session, response, "/login?expired=absolute") + return false + } if (!ensureFreshToken(session, user, response)) return false val refreshedUser = session.getAttribute(SessionKeys.USER) as? SessionUser ?: user - if (!revalidate(session, refreshedUser, response)) return false - return !redirectIfPasswordMustChange(session, refreshedUser, request, response) + return revalidate(session, refreshedUser, response) && + !redirectIfPasswordMustChange(session, refreshedUser, request, response) } /** true — отправлен redirect на смену пароля; false — запрос можно продолжать. */ @@ -122,9 +131,10 @@ class AuthInterceptor( private fun expire( session: HttpSession, response: HttpServletResponse, + redirect: String = "/login?expired=1", ) { session.invalidate() - response.sendRedirect("/login?expired=1") + response.sendRedirect(redirect) } private companion object { diff --git a/web/src/main/kotlin/ru/ysu/lms/web/session/SessionCookieRefreshFilter.kt b/web/src/main/kotlin/ru/ysu/lms/web/session/SessionCookieRefreshFilter.kt index e1bd6b0..af0b7e4 100644 --- a/web/src/main/kotlin/ru/ysu/lms/web/session/SessionCookieRefreshFilter.kt +++ b/web/src/main/kotlin/ru/ysu/lms/web/session/SessionCookieRefreshFilter.kt @@ -3,6 +3,7 @@ package ru.ysu.lms.web.session import jakarta.servlet.FilterChain import jakarta.servlet.http.HttpServletRequest import jakarta.servlet.http.HttpServletResponse +import jakarta.servlet.http.HttpSession import org.springframework.beans.factory.annotation.Value import org.springframework.http.HttpHeaders import org.springframework.http.ResponseCookie @@ -14,13 +15,18 @@ import java.time.Duration * Sliding-сессия: серверный timeout продлевается при активности, но cookie * с абсолютным Max-Age сама по себе не продлевается. Перевыпускаем cookie на * каждом ответе, чтобы через 7 дней активной работы не требовать пароль. + * Абсолютный срок жизни сессии (по умолчанию 30 дней) ограничивает и Max-Age: + * cookie не должна жить дольше, чем осталось до повторного входа. */ @Component class SessionCookieRefreshFilter( @Value("\${server.servlet.session.cookie.name:LMS_SESSION}") private val cookieName: String, @Value("\${server.servlet.session.timeout:7d}") private val sessionTtl: Duration, @Value("\${server.servlet.session.cookie.secure:true}") private val secureCookie: Boolean, + @Value("\${lms.session.absolute-timeout:30d}") absoluteTimeout: Duration = Duration.ofDays(30), ) : OncePerRequestFilter() { + private val lifetime = SessionLifetime(absoluteTimeout) + override fun doFilterInternal( request: HttpServletRequest, response: HttpServletResponse, @@ -29,6 +35,7 @@ class SessionCookieRefreshFilter( filterChain.doFilter(request, response) val session = request.getSession(false) ?: return if (response.isCommitted) return + val maxAge = cookieMaxAge(session) val cookie = ResponseCookie .from(cookieName, session.id) @@ -36,8 +43,17 @@ class SessionCookieRefreshFilter( .secure(secureCookie || request.isSecure) .path(request.contextPath.ifBlank { "/" }) .sameSite("Lax") - .maxAge(sessionTtl) + .maxAge(maxAge) .build() response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString()) } + + /** Max-Age не может пережить абсолютный срок сессии; истёкшая сессия получает 0. */ + private fun cookieMaxAge(session: HttpSession): Duration { + val remainingMillis = lifetime.remainingMillis(session) + if (remainingMillis <= 0) return Duration.ZERO + // Округляем вверх: иначе последняя неполная секунда даёт Max-Age=0 при живой сессии. + val remainingSeconds = (remainingMillis + 999) / 1000 + return Duration.ofSeconds(minOf(sessionTtl.seconds, remainingSeconds)) + } } diff --git a/web/src/main/kotlin/ru/ysu/lms/web/session/SessionLifetime.kt b/web/src/main/kotlin/ru/ysu/lms/web/session/SessionLifetime.kt new file mode 100644 index 0000000..7eaed57 --- /dev/null +++ b/web/src/main/kotlin/ru/ysu/lms/web/session/SessionLifetime.kt @@ -0,0 +1,31 @@ +package ru.ysu.lms.web.session + +import jakarta.servlet.http.HttpSession +import java.time.Duration + +/** + * Абсолютный срок жизни web-сессии. Sliding-timeout продлевается активностью, + * но после [absoluteTimeout] с момента входа пользователь обязан войти заново. + * Для сессий, созданных до появления [SessionKeys.AUTHENTICATED_AT], точкой + * отсчёта служит creationTime контейнера. + */ +class SessionLifetime( + private val absoluteTimeout: Duration, +) { + fun authenticatedAt(session: HttpSession): Long = + (session.getAttribute(SessionKeys.AUTHENTICATED_AT) as? Long) + ?: session.creationTime + + fun expiresAt(session: HttpSession): Long = authenticatedAt(session) + absoluteTimeout.toMillis() + + /** Остаток абсолютного срока в миллисекундах; 0 — срок уже истёк. */ + fun remainingMillis( + session: HttpSession, + now: Long = System.currentTimeMillis(), + ): Long = (expiresAt(session) - now).coerceAtLeast(0) + + fun isAbsoluteExpired( + session: HttpSession, + now: Long = System.currentTimeMillis(), + ): Boolean = now >= expiresAt(session) +} diff --git a/web/src/main/kotlin/ru/ysu/lms/web/session/SessionUser.kt b/web/src/main/kotlin/ru/ysu/lms/web/session/SessionUser.kt index cdfdb86..5d2c6d4 100644 --- a/web/src/main/kotlin/ru/ysu/lms/web/session/SessionUser.kt +++ b/web/src/main/kotlin/ru/ysu/lms/web/session/SessionUser.kt @@ -18,4 +18,5 @@ object SessionKeys { const val TOKEN_EXPIRES_AT = "lms.tokenExpiresAt" const val USER = "lms.user" const val STATUS_CHECKED_AT = "lms.statusCheckedAt" + const val AUTHENTICATED_AT = "lms.authenticatedAt" } diff --git a/web/src/main/resources/application.yml b/web/src/main/resources/application.yml index 822d5c8..44d2415 100644 --- a/web/src/main/resources/application.yml +++ b/web/src/main/resources/application.yml @@ -29,6 +29,9 @@ spring: lms: gateway: base-url: ${LMS_GATEWAY_URL:http://localhost:8080} + session: + # Жёсткий потолок жизни web-сессии: даже активный пользователь перелогинивается через 30 дней. + absolute-timeout: ${LMS_SESSION_ABSOLUTE_TIMEOUT:30d} management: endpoints: diff --git a/web/src/main/resources/templates/admin/audit.html b/web/src/main/resources/templates/admin/audit.html index f321323..c572808 100644 --- a/web/src/main/resources/templates/admin/audit.html +++ b/web/src/main/resources/templates/admin/audit.html @@ -66,14 +66,18 @@ diff --git a/web/src/main/resources/templates/admin/legacy-assets.html b/web/src/main/resources/templates/admin/legacy-assets.html new file mode 100644 index 0000000..21ecd80 --- /dev/null +++ b/web/src/main/resources/templates/admin/legacy-assets.html @@ -0,0 +1,48 @@ + + +Активы — LMS + +
+
+
+

Активы

+

+ Медиафайлы, на которые не ссылается ни одна статья. Они не удалены и доступны + только администраторам: обычные пользователи получают для них 404. +

+
+ К лабораторным +
+ +
+ + + + + + + + + + + + + + + + +
ФайлСсылка
file.png + file.png +
Бесхозных файлов нет.
+
+ +

+ Такие файлы остаются после удаления статей или переименования картинок. Если файл больше + не нужен, его можно удалить на стороне academic-service. +

+
+ + diff --git a/web/src/main/resources/templates/fragments/base.html b/web/src/main/resources/templates/fragments/base.html index 0537fd5..e7cd3c4 100644 --- a/web/src/main/resources/templates/fragments/base.html +++ b/web/src/main/resources/templates/fragments/base.html @@ -53,6 +53,7 @@ + diff --git a/web/src/main/resources/templates/login.html b/web/src/main/resources/templates/login.html index 68d5d25..170c58d 100644 --- a/web/src/main/resources/templates/login.html +++ b/web/src/main/resources/templates/login.html @@ -8,6 +8,7 @@
Аккаунт администратора ещё не создан. Создать первый аккаунт
+
Срок сессии истёк. Войдите заново.
diff --git a/web/src/test/kotlin/ru/ysu/lms/web/admin/AdminAuditTemplateTest.kt b/web/src/test/kotlin/ru/ysu/lms/web/admin/AdminAuditTemplateTest.kt index 90c0337..0fb6600 100644 --- a/web/src/test/kotlin/ru/ysu/lms/web/admin/AdminAuditTemplateTest.kt +++ b/web/src/test/kotlin/ru/ysu/lms/web/admin/AdminAuditTemplateTest.kt @@ -35,8 +35,10 @@ class AdminAuditTemplateTest { "action" to "GRADE_SET", "limit" to 100, "page" to 1, + "nextCursor" to "cursor-1", "hasNext" to true, "hasPrev" to true, + "isCursor" to false, "actionOptions" to AuditViews.actionOptions("GRADE_SET"), ), ) @@ -62,7 +64,8 @@ class AdminAuditTemplateTest { assertThat(html).contains("action=GRADE_SET") assertThat(html).contains("limit=100") assertThat(html).contains("page=0") - assertThat(html).contains("page=2") + assertThat(html).contains("cursor=cursor-1") + assertThat(html).doesNotContain("page=2") } @Test @@ -75,8 +78,10 @@ class AdminAuditTemplateTest { "action" to "", "limit" to 100, "page" to 0, + "nextCursor" to null, "hasNext" to false, "hasPrev" to false, + "isCursor" to false, "actionOptions" to AuditViews.actionOptions(null), ), ) @@ -88,6 +93,33 @@ class AdminAuditTemplateTest { assertThat(html).doesNotContain(">Показать ещё") } + @Test + fun `cursor mode links next page with cursor and resets to start`() { + val html = + render( + "admin/audit", + mapOf( + "events" to auditRows(), + "action" to "GRADE_SET", + "limit" to 50, + "page" to 0, + "nextCursor" to "cursor-2", + "hasNext" to true, + "hasPrev" to false, + "isCursor" to true, + "actionOptions" to AuditViews.actionOptions("GRADE_SET"), + ), + ) + + assertThat(html).contains("Продолжение") + assertThat(html).contains("cursor=cursor-2") + assertThat(html).contains("action=GRADE_SET") + assertThat(html).contains("limit=50") + assertThat(html).contains(">В начало") + assertThat(html).doesNotContain("page=1") + assertThat(html).doesNotContain(">Назад") + } + @Test fun `keeps unknown action filter selectable`() { val html = @@ -98,8 +130,10 @@ class AdminAuditTemplateTest { "action" to "CUSTOM_ACTION", "limit" to 100, "page" to 2, + "nextCursor" to "cursor-9", "hasNext" to true, "hasPrev" to true, + "isCursor" to false, "actionOptions" to AuditViews.actionOptions("CUSTOM_ACTION"), ), ) @@ -107,7 +141,8 @@ class AdminAuditTemplateTest { assertThat(html).contains("value=\"CUSTOM_ACTION\"") assertThat(html).contains("CUSTOM_ACTION") assertThat(html).contains("page=1") - assertThat(html).contains("page=3") + assertThat(html).contains("cursor=cursor-9") + assertThat(html).doesNotContain("page=3") } private fun auditRows(): List = diff --git a/web/src/test/kotlin/ru/ysu/lms/web/admin/LegacyAssetsTemplateTest.kt b/web/src/test/kotlin/ru/ysu/lms/web/admin/LegacyAssetsTemplateTest.kt new file mode 100644 index 0000000..6a6d7c5 --- /dev/null +++ b/web/src/test/kotlin/ru/ysu/lms/web/admin/LegacyAssetsTemplateTest.kt @@ -0,0 +1,77 @@ +package ru.ysu.lms.web.admin + +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import org.springframework.mock.web.MockHttpServletRequest +import org.springframework.mock.web.MockHttpServletResponse +import org.springframework.mock.web.MockServletContext +import org.thymeleaf.context.WebContext +import org.thymeleaf.spring6.SpringTemplateEngine +import org.thymeleaf.templatemode.TemplateMode +import org.thymeleaf.templateresolver.ClassLoaderTemplateResolver +import org.thymeleaf.web.servlet.JakartaServletWebApplication +import ru.ysu.lms.web.session.SessionUser +import java.util.Locale + +class LegacyAssetsTemplateTest { + private val engine = + SpringTemplateEngine().apply { + setTemplateResolver( + ClassLoaderTemplateResolver().apply { + prefix = "templates/" + suffix = ".html" + characterEncoding = "UTF-8" + templateMode = TemplateMode.HTML + }, + ) + } + + @Test + fun `renders orphan assets with links and admin-only explanation`() { + val html = render(listOf("old-picture.png", "unused-video.mp4")) + + assertThat(html).contains("Активы") + assertThat(html).contains("old-picture.png") + assertThat(html).contains("unused-video.mp4") + assertThat(html).contains("href=\"/api/v1/content/assets/old-picture.png\"") + assertThat(html).contains("href=\"/api/v1/content/assets/unused-video.mp4\"") + assertThat(html).contains("не ссылается ни одна статья") + assertThat(html).contains("только администраторам") + assertThat(html).doesNotContain("Бесхозных файлов нет") + assertThat(html).contains(">Активы") + assertThat(html).contains("href=\"/admin/labs\"") + } + + @Test + fun `renders empty legacy assets state`() { + val html = render(emptyList()) + + assertThat(html).contains("Активы") + assertThat(html).contains("Бесхозных файлов нет") + assertThat(html).doesNotContain("/api/v1/content/assets/") + } + + private fun render(assets: List): String { + val servletContext = MockServletContext() + val application = JakartaServletWebApplication.buildApplication(servletContext) + val exchange = + application.buildExchange( + MockHttpServletRequest(servletContext), + MockHttpServletResponse(), + ) + val context = WebContext(exchange, Locale.forLanguageTag("ru")) + context.setVariable("csrfToken", "csrf") + context.setVariable( + "currentUser", + SessionUser( + id = "admin-id", + login = "admin", + role = "ADMIN", + name = "Администратор", + mustChangePassword = false, + ), + ) + context.setVariable("assets", assets) + return engine.process("admin/legacy-assets", context) + } +} diff --git a/web/src/test/kotlin/ru/ysu/lms/web/api/ApiClientAuditTest.kt b/web/src/test/kotlin/ru/ysu/lms/web/api/ApiClientAuditTest.kt index cf0b792..5ccd631 100644 --- a/web/src/test/kotlin/ru/ysu/lms/web/api/ApiClientAuditTest.kt +++ b/web/src/test/kotlin/ru/ysu/lms/web/api/ApiClientAuditTest.kt @@ -25,6 +25,31 @@ class ApiClientAuditTest { } } + @Test + fun `listAudit sends cursor for keyset pagination`() { + val query = AtomicReference() + val authorization = AtomicReference() + withAuditServer(query, authorization) { baseUrl -> + val events = ApiClient(baseUrl, ObjectMapper()).listAudit("session-token", cursor = "opaque-cursor") + + assertThat(events).hasSize(1) + assertThat(events[0].cursor).isEqualTo("next-cursor") + assertThat(query.get()).contains("limit=100") + assertThat(query.get()).contains("cursor=opaque-cursor") + } + } + + @Test + fun `listAudit omits blank cursor`() { + val query = AtomicReference() + val authorization = AtomicReference() + withAuditServer(query, authorization) { baseUrl -> + ApiClient(baseUrl, ObjectMapper()).listAudit("session-token", cursor = " ") + + assertThat(query.get()).doesNotContain("cursor=") + } + } + @Test fun `listAudit defaults to first page without filter`() { val query = AtomicReference() @@ -61,7 +86,8 @@ class ApiClientAuditTest { "entityId": "g1", "courseId": "c1", "studentId": "s1", - "details": "Оценка 5" + "details": "Оценка 5", + "cursor": "next-cursor" } ] """.trimIndent(), diff --git a/web/src/test/kotlin/ru/ysu/lms/web/api/AuditDtoTest.kt b/web/src/test/kotlin/ru/ysu/lms/web/api/AuditDtoTest.kt index 39421b3..b0d03be 100644 --- a/web/src/test/kotlin/ru/ysu/lms/web/api/AuditDtoTest.kt +++ b/web/src/test/kotlin/ru/ysu/lms/web/api/AuditDtoTest.kt @@ -23,6 +23,7 @@ class AuditDtoTest { "courseId": "c1", "studentId": "s1", "details": "Оценка 5", + "cursor": "next-cursor", "unknownField": "ignored" }, { @@ -53,6 +54,7 @@ class AuditDtoTest { assertThat(events[0].courseId).isEqualTo("c1") assertThat(events[0].studentId).isEqualTo("s1") assertThat(events[0].details).isEqualTo("Оценка 5") + assertThat(events[0].cursor).isEqualTo("next-cursor") assertThat(events[1].id).isEqualTo("e2") assertThat(events[1].actorAccountId).isNull() @@ -61,5 +63,6 @@ class AuditDtoTest { assertThat(events[1].courseId).isNull() assertThat(events[1].studentId).isNull() assertThat(events[1].details).isEmpty() + assertThat(events[1].cursor).isNull() } } diff --git a/web/src/test/kotlin/ru/ysu/lms/web/auth/AuthPageControllerTest.kt b/web/src/test/kotlin/ru/ysu/lms/web/auth/AuthPageControllerTest.kt index c1606fb..e34c49d 100644 --- a/web/src/test/kotlin/ru/ysu/lms/web/auth/AuthPageControllerTest.kt +++ b/web/src/test/kotlin/ru/ysu/lms/web/auth/AuthPageControllerTest.kt @@ -20,6 +20,7 @@ import org.springframework.test.web.servlet.setup.MockMvcBuilders import ru.ysu.lms.web.api.ApiClient import ru.ysu.lms.web.api.ApiException import ru.ysu.lms.web.api.AuthResponseDto +import ru.ysu.lms.web.api.adminExists import ru.ysu.lms.web.api.changePassword import ru.ysu.lms.web.api.login import ru.ysu.lms.web.api.logout @@ -73,6 +74,33 @@ class AuthPageControllerTest { assertThat((session.getAttribute(SessionKeys.USER) as SessionUser).login).isEqualTo("student") } + @Test + fun `login stores authenticated at timestamp`() { + every { api.login("student", "secret") } returns + AuthResponseDto( + token = "access", + refreshToken = "refresh", + accessTokenExpiresIn = 900, + id = "s1", + login = "student", + role = "STUDENT", + name = "Студент", + mustChangePassword = false, + ) + val before = System.currentTimeMillis() + + val result = + mockMvc + .perform(post("/login").param("login", "student").param("password", "secret")) + .andExpect(status().is3xxRedirection) + .andReturn() + + val session = result.request.getSession(false) as MockHttpSession + val authenticatedAt = session.getAttribute(SessionKeys.AUTHENTICATED_AT) as Long + assertThat(authenticatedAt).isGreaterThanOrEqualTo(before) + assertThat(authenticatedAt).isLessThanOrEqualTo(System.currentTimeMillis()) + } + @Test fun `login without refresh token still creates the session`() { every { api.login("student", "secret") } returns @@ -96,6 +124,17 @@ class AuthPageControllerTest { assertThat(session.getAttribute(SessionKeys.REFRESH_TOKEN)).isNull() } + @Test + fun `login page shows session expired message`() { + every { api.adminExists() } returns true + val model = org.springframework.ui.ExtendedModelMap() + + val view = AuthPageController(api).loginPage("absolute", MockHttpSession(), model) + + assertThat(view).isEqualTo("login") + assertThat(model["error"]).isEqualTo("Срок сессии истёк. Войдите заново.") + } + @Test fun `logout revokes refresh token and invalidates session`() { every { api.logout("refresh") } just Runs diff --git a/web/src/test/kotlin/ru/ysu/lms/web/session/AuthInterceptorTest.kt b/web/src/test/kotlin/ru/ysu/lms/web/session/AuthInterceptorTest.kt index ddea49b..2e94915 100644 --- a/web/src/test/kotlin/ru/ysu/lms/web/session/AuthInterceptorTest.kt +++ b/web/src/test/kotlin/ru/ysu/lms/web/session/AuthInterceptorTest.kt @@ -22,6 +22,7 @@ import ru.ysu.lms.web.api.ApiException import ru.ysu.lms.web.api.AuthResponseDto import ru.ysu.lms.web.api.MeDto import ru.ysu.lms.web.api.refresh +import java.time.Duration class AuthInterceptorTest { @RestController @@ -35,7 +36,7 @@ class AuthInterceptorTest { private val mockMvc: MockMvc = MockMvcBuilders .standaloneSetup(TestPage()) - .addInterceptors(AuthInterceptor(api, SessionTokenService(api))) + .addInterceptors(AuthInterceptor(api, SessionTokenService(api), Duration.ofDays(30))) .build() @BeforeEach @@ -141,6 +142,38 @@ class AuthInterceptorTest { assertThat(user.mustChangePassword).isTrue() } + @Test + fun `absolute expired session redirects to absolute login without identity call`() { + val session = + sessionFor( + authenticatedAt = System.currentTimeMillis() - Duration.ofDays(31).toMillis(), + ) + + mockMvc + .perform(get("/test").session(session)) + .andExpect(status().is3xxRedirection) + .andExpect(redirectedUrl("/login?expired=absolute")) + + assertThat(session.isInvalid).isTrue() + verify(exactly = 0) { api.refresh(any()) } + verify(exactly = 0) { api.me(any()) } + } + + @Test + fun `session younger than absolute timeout passes`() { + val session = + sessionFor( + authenticatedAt = System.currentTimeMillis() - Duration.ofDays(29).toMillis(), + ) + session.setAttribute(SessionKeys.STATUS_CHECKED_AT, System.currentTimeMillis()) + + mockMvc + .perform(get("/test").session(session)) + .andExpect(status().isOk) + + assertThat(session.isInvalid).isFalse() + } + @Test fun `anonymous user is redirected to login`() { mockMvc @@ -233,10 +266,12 @@ class AuthInterceptorTest { mustChangePassword: Boolean = false, expiresInMs: Long = 600_000L, refreshToken: String? = REFRESH_TOKEN, + authenticatedAt: Long = System.currentTimeMillis(), ): MockHttpSession = MockHttpSession().apply { setAttribute(SessionKeys.TOKEN, TOKEN) setAttribute(SessionKeys.TOKEN_EXPIRES_AT, System.currentTimeMillis() + expiresInMs) + setAttribute(SessionKeys.AUTHENTICATED_AT, authenticatedAt) if (refreshToken != null) setAttribute(SessionKeys.REFRESH_TOKEN, refreshToken) setAttribute( SessionKeys.USER, diff --git a/web/src/test/kotlin/ru/ysu/lms/web/session/SessionCookieRefreshFilterTest.kt b/web/src/test/kotlin/ru/ysu/lms/web/session/SessionCookieRefreshFilterTest.kt index f50c32f..7be6be3 100644 --- a/web/src/test/kotlin/ru/ysu/lms/web/session/SessionCookieRefreshFilterTest.kt +++ b/web/src/test/kotlin/ru/ysu/lms/web/session/SessionCookieRefreshFilterTest.kt @@ -8,12 +8,18 @@ import org.springframework.mock.web.MockHttpServletResponse import java.time.Duration class SessionCookieRefreshFilterTest { - private val filter = SessionCookieRefreshFilter("LMS_SESSION", Duration.ofDays(7), secureCookie = false) + private val filter = + SessionCookieRefreshFilter( + "LMS_SESSION", + Duration.ofDays(7), + secureCookie = false, + absoluteTimeout = Duration.ofDays(30), + ) @Test fun `refreshes cookie max age on every existing session`() { val request = MockHttpServletRequest() - request.getSession(true) + request.getSession(true)!!.setAttribute(SessionKeys.AUTHENTICATED_AT, System.currentTimeMillis()) val response = MockHttpServletResponse() filter.doFilter(request, response, MockFilterChain()) @@ -25,6 +31,42 @@ class SessionCookieRefreshFilterTest { assertThat(cookie).contains("SameSite=Lax") } + @Test + fun `cookie max age is capped by remaining absolute lifetime`() { + val request = MockHttpServletRequest() + request + .getSession(true)!! + .setAttribute( + SessionKeys.AUTHENTICATED_AT, + System.currentTimeMillis() - Duration.ofDays(29).toMillis(), + ) + val response = MockHttpServletResponse() + + filter.doFilter(request, response, MockFilterChain()) + + val maxAge = maxAgeOf(response.getHeader("Set-Cookie")) + assertThat(maxAge).isBetween(86_000L, 86_400L) + assertThat(maxAge).isLessThan(Duration.ofDays(7).seconds) + } + + @Test + fun `absolute expired session receives zero max age cookie`() { + val request = MockHttpServletRequest() + request + .getSession(true)!! + .setAttribute( + SessionKeys.AUTHENTICATED_AT, + System.currentTimeMillis() - Duration.ofDays(31).toMillis(), + ) + val response = MockHttpServletResponse() + + filter.doFilter(request, response, MockFilterChain()) + + val cookie = response.getHeader("Set-Cookie") + assertThat(cookie).contains("LMS_SESSION=") + assertThat(cookie).contains("Max-Age=0") + } + @Test fun `does not create cookie without session`() { val response = MockHttpServletResponse() @@ -33,4 +75,12 @@ class SessionCookieRefreshFilterTest { assertThat(response.getHeader("Set-Cookie")).isNull() } + + private fun maxAgeOf(cookie: String?): Long = + Regex("Max-Age=(-?\\d+)") + .find(cookie.orEmpty()) + ?.groupValues + ?.get(1) + ?.toLong() + ?: error("Max-Age is missing in cookie: $cookie") }